feat: add the tiktok integration
This commit is contained in:
15
.env.example
15
.env.example
@@ -9,15 +9,18 @@
|
|||||||
# server with the `prisma dev` CLI command, when not choosing any non-default ports or settings. The API key, unlike the
|
# server with the `prisma dev` CLI command, when not choosing any non-default ports or settings. The API key, unlike the
|
||||||
# one found in a remote Prisma Postgres URL, does not contain any sensitive information.
|
# one found in a remote Prisma Postgres URL, does not contain any sensitive information.
|
||||||
|
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
DATABASE_URL="postgresql://wyview:wyview@localhost:5432/wyview"
|
DATABASE_URL="postgresql://wyview:wyview@localhost:5432/wyview"
|
||||||
|
|
||||||
NEXT_PUBLIC_PASSWORD=Azerty123
|
NEXT_PUBLIC_PASSWORD=Azerty123
|
||||||
NEXTAUTH_SECRET=unsecretaleatoire
|
NEXTAUTH_SECRET=***
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
NEXTAUTH_URL=*vps_ip_address*
|
||||||
AUTH_SECRET=secretaleatoire
|
AUTH_SECRET=***
|
||||||
|
|
||||||
|
|
||||||
# TikTok API credentials
|
# TikTok API credentials
|
||||||
TIKTOK_CLIENT_KEY=*******
|
TIKTOK_CLIENT_KEY=***
|
||||||
TIKTOK_CLIENT_SECRET=*******
|
TIKTOK_CLIENT_SECRET=***
|
||||||
TIKTOK_REDIRECT_URI_DEV=http://localhost:3000/api/tiktok/callback
|
TIKTOK_REDIRECT_URI_DEV=http://localhost:3000/api/tiktok/callback
|
||||||
TIKTOK_REDIRECT_URI_PROD=https://marouette.fun/api/tiktok/callback
|
TIKTOK_REDIRECT_URI_PROD=*vps_ip_address*/api/tiktok/callback
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ WORKDIR /app
|
|||||||
ARG DATABASE_URL
|
ARG DATABASE_URL
|
||||||
ENV DATABASE_URL=$DATABASE_URL
|
ENV DATABASE_URL=$DATABASE_URL
|
||||||
|
|
||||||
|
ARG AUTH_SECRET
|
||||||
|
ENV AUTH_SECRET=$AUTH_SECRET
|
||||||
|
|
||||||
|
ARG NEXTAUTH_SECRET
|
||||||
|
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
COPY prisma.config.ts ./
|
COPY prisma.config.ts ./
|
||||||
|
|||||||
59
app/api/auth/callback/tiktok/route.ts
Normal file
59
app/api/auth/callback/tiktok/route.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { exchangeCodeForTokens } from "@/lib/tiktok";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const code = searchParams.get("code");
|
||||||
|
const state = searchParams.get("state");
|
||||||
|
const error = searchParams.get("error");
|
||||||
|
|
||||||
|
if (error || !code || !state) {
|
||||||
|
return NextResponse.redirect(new URL("/tiktok?error=access_denied", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Récupérer verifier + userId depuis la DB via le state
|
||||||
|
const pkceRecord = await prisma.tikTokPKCE.findUnique({ where: { state } });
|
||||||
|
if (!pkceRecord) {
|
||||||
|
return NextResponse.redirect(new URL("/tiktok?error=missing_verifier", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supprimer le record PKCE (usage unique)
|
||||||
|
await prisma.tikTokPKCE.delete({ where: { state } });
|
||||||
|
|
||||||
|
const { userId, codeVerifier } = pkceRecord;
|
||||||
|
|
||||||
|
const tokens = await exchangeCodeForTokens(code, codeVerifier);
|
||||||
|
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
||||||
|
|
||||||
|
await prisma.tikTokToken.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
openId: tokens.open_id,
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
openId: tokens.open_id,
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = await prisma.trackedAccount.findFirst({ where: { userId, platform: "tiktok" } });
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.trackedAccount.create({
|
||||||
|
data: { userId, platform: "tiktok", username: tokens.open_id, accountId: tokens.open_id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL("/tiktok?connected=1", request.url));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TikTok callback error]", err);
|
||||||
|
return NextResponse.redirect(new URL("/tiktok?error=token_exchange", request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/tiktok/callback/route.ts
Normal file
51
app/api/tiktok/callback/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { exchangeCodeForTokens } from "@/lib/tiktok";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const code = searchParams.get("code");
|
||||||
|
const state = searchParams.get("state");
|
||||||
|
const error = searchParams.get("error");
|
||||||
|
|
||||||
|
if (error || !code || !state) {
|
||||||
|
return NextResponse.redirect(new URL("/tiktok?error=access_denied", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// userId stocké dans le record PKCE lors du /connect (pas besoin de session)
|
||||||
|
const pkceRecord = await prisma.tikTokPKCE.findUnique({ where: { state } });
|
||||||
|
if (!pkceRecord) {
|
||||||
|
return NextResponse.redirect(new URL("/tiktok?error=missing_verifier", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.tikTokPKCE.delete({ where: { state } });
|
||||||
|
|
||||||
|
const { userId, codeVerifier } = pkceRecord;
|
||||||
|
|
||||||
|
console.log("[TikTok callback] codeVerifier from DB:", codeVerifier);
|
||||||
|
console.log("[TikTok callback] code from TikTok:", code);
|
||||||
|
|
||||||
|
const tokens = await exchangeCodeForTokens(code, codeVerifier);
|
||||||
|
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
||||||
|
|
||||||
|
await prisma.tikTokToken.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: { userId, openId: tokens.open_id, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt },
|
||||||
|
update: { openId: tokens.open_id, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt },
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = await prisma.trackedAccount.findFirst({ where: { userId, platform: "tiktok" } });
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.trackedAccount.create({
|
||||||
|
data: { userId, platform: "tiktok", username: tokens.open_id, accountId: tokens.open_id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL("/tiktok?connected=1", request.url));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TikTok callback error]", err);
|
||||||
|
return NextResponse.redirect(new URL("/tiktok?error=token_exchange", request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
34
app/api/tiktok/connect/route.ts
Normal file
34
app/api/tiktok/connect/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getTikTokAuthUrl, generateCodeVerifier, generateCodeChallenge } from "@/lib/tiktok";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = (session.user as { id?: string }).id;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Session error" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = crypto.randomUUID();
|
||||||
|
const codeVerifier = generateCodeVerifier();
|
||||||
|
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
console.log("[TikTok PKCE] verifier:", codeVerifier);
|
||||||
|
console.log("[TikTok PKCE] challenge:", codeChallenge);
|
||||||
|
console.log("[TikTok PKCE] verifier length:", codeVerifier.length);
|
||||||
|
|
||||||
|
// Stocker state + verifier + userId en DB
|
||||||
|
await prisma.tikTokPKCE.create({
|
||||||
|
data: { state, codeVerifier, userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const authUrl = getTikTokAuthUrl(state, codeChallenge);
|
||||||
|
console.log("[TikTok PKCE] authUrl:", authUrl);
|
||||||
|
return NextResponse.redirect(authUrl);
|
||||||
|
}
|
||||||
30
app/api/tiktok/disconnect/route.ts
Normal file
30
app/api/tiktok/disconnect/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = (session.user as { id?: string }).id;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Session error" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Supprimer le token TikTok
|
||||||
|
await prisma.tikTokToken.deleteMany({ where: { userId } });
|
||||||
|
|
||||||
|
// Supprimer le TrackedAccount associé
|
||||||
|
await prisma.trackedAccount.deleteMany({ where: { userId, platform: "tiktok" } });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TikTok disconnect error]", err);
|
||||||
|
return NextResponse.json({ error: "Disconnect failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
58
app/api/tiktok/stats/route.ts
Normal file
58
app/api/tiktok/stats/route.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { fetchUserStats, refreshAccessToken } from "@/lib/tiktok";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = (session.user as { id?: string }).id;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Session error" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenRecord = await prisma.tikTokToken.findUnique({ where: { userId } });
|
||||||
|
|
||||||
|
if (!tokenRecord) {
|
||||||
|
return NextResponse.json({ error: "Not connected" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let { accessToken, refreshToken, openId, expiresAt } = tokenRecord;
|
||||||
|
|
||||||
|
// Refresh si expiré (avec 60s de marge)
|
||||||
|
if (expiresAt.getTime() - Date.now() < 60_000) {
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshAccessToken(refreshToken);
|
||||||
|
const newExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000);
|
||||||
|
|
||||||
|
await prisma.tikTokToken.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
accessToken: refreshed.access_token,
|
||||||
|
refreshToken: refreshed.refresh_token,
|
||||||
|
expiresAt: newExpiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
accessToken = refreshed.access_token;
|
||||||
|
refreshToken = refreshed.refresh_token;
|
||||||
|
expiresAt = newExpiresAt;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TikTok stats refresh error]", err);
|
||||||
|
return NextResponse.json({ error: "Token refresh failed" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fetchUserStats(accessToken, openId);
|
||||||
|
return NextResponse.json(stats);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TikTok stats fetch error]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch stats" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,33 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Music2, Link2, Unlink, Loader2, AlertTriangle } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import StatCard from "@/components/StatCard";
|
import StatCard from "@/components/StatCard";
|
||||||
import { Music2 } from "lucide-react";
|
|
||||||
|
interface TikTokStats {
|
||||||
|
followers: number;
|
||||||
|
likes: number;
|
||||||
|
videoCount: number;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function TikTokPage() {
|
export default function TikTokPage() {
|
||||||
|
const [stats, setStats] = useState<TikTokStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [disconnecting, setDisconnecting] = useState(false);
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/tiktok/stats");
|
||||||
|
if (res.status === 404) {
|
||||||
|
setStats(null);
|
||||||
|
} else if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error ?? "Erreur inconnue");
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setStats(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Erreur réseau");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleDisconnect() {
|
||||||
|
setDisconnecting(true);
|
||||||
|
try {
|
||||||
|
await fetch("/api/tiktok/disconnect", { method: "POST" });
|
||||||
|
setStats(null);
|
||||||
|
router.replace("/tiktok");
|
||||||
|
} finally {
|
||||||
|
setDisconnecting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlError = searchParams.get("error");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-8">
|
{/* Header */}
|
||||||
<div className="p-2 rounded-sm bg-pink-500/10 border border-pink-500/20">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<Music2 size={18} className="text-pink-400" />
|
<div className="flex items-center gap-3">
|
||||||
</div>
|
<div className="p-2 rounded-sm bg-pink-500/10 border border-pink-500/20">
|
||||||
<div>
|
<Music2 size={18} className="text-pink-400" />
|
||||||
<div className="text-[9px] font-mono tracking-[0.3em] text-pink-400/50 uppercase mb-0.5">Plateforme</div>
|
</div>
|
||||||
<h1 className="text-2xl font-black tracking-widest text-white uppercase">TikTok</h1>
|
<div>
|
||||||
|
<div className="text-[9px] font-mono tracking-[0.3em] text-pink-400/50 uppercase mb-0.5">Plateforme</div>
|
||||||
|
<h1 className="text-2xl font-black tracking-widest text-white uppercase">TikTok</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{stats && !loading && (
|
||||||
|
<button
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
disabled={disconnecting}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-[10px] font-mono tracking-widest uppercase border border-red-500/30 text-red-400/70 hover:text-red-400 hover:border-red-500/60 rounded-sm transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{disconnecting ? <Loader2 size={12} className="animate-spin" /> : <Unlink size={12} />}
|
||||||
|
Déconnecter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
|
{/* Erreur URL */}
|
||||||
<StatCard label="Followers" value="—" sub="Aucune donnée" accent="red" />
|
{urlError && (
|
||||||
<StatCard label="Likes totaux" value="—" sub="Aucune donnée" accent="red" />
|
<div className="flex items-center gap-2 mb-6 px-4 py-3 bg-red-500/5 border border-red-500/20 rounded-sm text-red-400 text-xs font-mono">
|
||||||
<StatCard label="Vues totales" value="—" sub="Aucune donnée" accent="red" />
|
<AlertTriangle size={14} />
|
||||||
<StatCard label="Vidéos publiées" value="—" sub="Aucune donnée" accent="red" />
|
{urlError === "access_denied" && "Accès refusé par TikTok."}
|
||||||
</div>
|
{urlError === "token_exchange" && "Erreur lors de l'échange du token."}
|
||||||
|
{urlError === "session_error" && "Erreur de session."}
|
||||||
|
{!["access_denied", "token_exchange", "session_error"].includes(urlError) && `Erreur : ${urlError}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bg-[#0d1210] border border-[#1a2a1a] rounded-sm p-8 flex flex-col items-center justify-center gap-3 min-h-[200px]">
|
{/* Chargement */}
|
||||||
<Music2 size={32} className="text-pink-400/20" />
|
{loading && (
|
||||||
<p className="text-[#3a5a3a] font-mono text-xs tracking-widest text-center">
|
<div className="flex items-center justify-center min-h-[200px] gap-3 text-[#3a5a3a] font-mono text-xs">
|
||||||
CONNECTEZ VOTRE COMPTE TIKTOK<br />POUR AFFICHER VOS STATISTIQUES
|
<Loader2 size={16} className="animate-spin text-pink-400/40" />
|
||||||
</p>
|
Chargement...
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connecté : stats */}
|
||||||
|
{!loading && stats && (
|
||||||
|
<>
|
||||||
|
{stats.displayName && (
|
||||||
|
<div className="flex items-center gap-3 mb-6 px-4 py-3 bg-pink-500/5 border border-pink-500/20 rounded-sm">
|
||||||
|
{stats.avatarUrl && (
|
||||||
|
<img src={stats.avatarUrl} alt="avatar" className="w-8 h-8 rounded-full object-cover border border-pink-500/20" />
|
||||||
|
)}
|
||||||
|
<span className="text-pink-400 font-mono text-sm font-bold">{stats.displayName}</span>
|
||||||
|
<span className="text-[9px] font-mono tracking-widest text-pink-400/30 uppercase ml-auto">Connecté</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
|
||||||
|
<StatCard
|
||||||
|
label="Followers"
|
||||||
|
value={stats.followers.toLocaleString("fr-FR")}
|
||||||
|
sub="Abonnés totaux"
|
||||||
|
accent="purple"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Likes totaux"
|
||||||
|
value={stats.likes.toLocaleString("fr-FR")}
|
||||||
|
sub="Cumul likes"
|
||||||
|
accent="red"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Vidéos publiées"
|
||||||
|
value={stats.videoCount.toLocaleString("fr-FR")}
|
||||||
|
sub="Vidéos au total"
|
||||||
|
accent="blue"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Ratio likes/vidéo"
|
||||||
|
value={stats.videoCount > 0 ? Math.round(stats.likes / stats.videoCount).toLocaleString("fr-FR") : "—"}
|
||||||
|
sub="Moy. likes par vidéo"
|
||||||
|
accent="gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Non connecté */}
|
||||||
|
{!loading && !stats && !error && (
|
||||||
|
<div className="bg-[#0d1210] border border-[#1a2a1a] rounded-sm p-12 flex flex-col items-center justify-center gap-5 min-h-[240px]">
|
||||||
|
<Music2 size={36} className="text-pink-400/15" />
|
||||||
|
<p className="text-[#3a5a3a] font-mono text-xs tracking-widest text-center uppercase">
|
||||||
|
Connectez votre compte TikTok<br />pour afficher vos statistiques
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/api/tiktok/connect"
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 text-[10px] font-mono tracking-widest uppercase bg-pink-500/10 border border-pink-500/30 text-pink-400 hover:bg-pink-500/20 hover:border-pink-500/50 rounded-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Link2 size={13} />
|
||||||
|
Connecter TikTok
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Erreur API */}
|
||||||
|
{!loading && error && (
|
||||||
|
<div className="bg-[#0d1210] border border-red-500/20 rounded-sm p-8 flex flex-col items-center justify-center gap-3 min-h-[200px]">
|
||||||
|
<AlertTriangle size={28} className="text-red-400/40" />
|
||||||
|
<p className="text-red-400/60 font-mono text-xs tracking-widest text-center uppercase">{error}</p>
|
||||||
|
<button onClick={loadStats} className="text-[10px] font-mono tracking-widest text-[#3a5a3a] hover:text-pink-400 uppercase transition-colors">
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ services:
|
|||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DATABASE_URL=postgresql://wyview:wyview_password@postgres:5432/wyview
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- NEXTAUTH_URL=http://localhost:3001
|
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||||
- NEXTAUTH_SECRET=your-secret-key-change-this-in-production
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||||
|
- AUTH_SECRET=${AUTH_SECRET}
|
||||||
|
- TIKTOK_CLIENT_KEY=${TIKTOK_CLIENT_KEY}
|
||||||
|
- TIKTOK_CLIENT_SECRET=${TIKTOK_CLIENT_SECRET}
|
||||||
|
- TIKTOK_REDIRECT_URI_PROD=${TIKTOK_REDIRECT_URI_PROD}
|
||||||
|
- TIKTOK_REDIRECT_URI_DEV=${TIKTOK_REDIRECT_URI_DEV}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
],
|
],
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
|
if (token?.id && session.user) {
|
||||||
|
(session.user as { id?: string }).id = token.id as string;
|
||||||
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
async jwt({ token, user }) {
|
async jwt({ token, user }) {
|
||||||
|
|||||||
150
lib/tiktok.ts
Normal file
150
lib/tiktok.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
const TIKTOK_AUTH_URL = "https://www.tiktok.com/v2/auth/authorize";
|
||||||
|
const TIKTOK_TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/";
|
||||||
|
const TIKTOK_USER_INFO_URL = "https://open.tiktokapis.com/v2/user/info/";
|
||||||
|
|
||||||
|
const CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY!;
|
||||||
|
const CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET!;
|
||||||
|
|
||||||
|
function getRedirectUri(): string {
|
||||||
|
return process.env.NODE_ENV === "production"
|
||||||
|
? process.env.TIKTOK_REDIRECT_URI_PROD!
|
||||||
|
: process.env.TIKTOK_REDIRECT_URI_DEV!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PKCE helpers ──────────────────────────────────────────────
|
||||||
|
export function generateCodeVerifier(): string {
|
||||||
|
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
let result = "";
|
||||||
|
const array = new Uint8Array(64);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
result += charset[array[i] % charset.length];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||||
|
const data = new TextEncoder().encode(verifier);
|
||||||
|
const digest = await globalThis.crypto.subtle.digest("SHA-256", data);
|
||||||
|
const bytes = Array.from(new Uint8Array(digest));
|
||||||
|
return btoa(bytes.map((b) => String.fromCharCode(b)).join(""))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getTikTokAuthUrl(state: string, codeChallenge: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_key: CLIENT_KEY,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "user.info.basic,user.info.stats",
|
||||||
|
redirect_uri: getRedirectUri(),
|
||||||
|
state,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
});
|
||||||
|
return `${TIKTOK_AUTH_URL}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TikTokTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
open_id: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_expires_in: number;
|
||||||
|
token_type: string;
|
||||||
|
scope: string;
|
||||||
|
error?: string;
|
||||||
|
error_description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCodeForTokens(code: string, codeVerifier: string): Promise<TikTokTokenResponse> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_key: CLIENT_KEY,
|
||||||
|
client_secret: CLIENT_SECRET,
|
||||||
|
code,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
redirect_uri: getRedirectUri(),
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(TIKTOK_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || data.error) {
|
||||||
|
throw new Error(data.error_description || data.error || "TikTok token exchange failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken(refreshToken: string): Promise<TikTokTokenResponse> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_key: CLIENT_KEY,
|
||||||
|
client_secret: CLIENT_SECRET,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(TIKTOK_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || data.error) {
|
||||||
|
throw new Error(data.error_description || data.error || "TikTok token refresh failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TikTokUserStats {
|
||||||
|
followers: number;
|
||||||
|
likes: number;
|
||||||
|
videoCount: number;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserStats(accessToken: string, openId: string): Promise<TikTokUserStats> {
|
||||||
|
const fields = "follower_count,following_count,likes_count,video_count,display_name,avatar_url";
|
||||||
|
const url = `${TIKTOK_USER_INFO_URL}?fields=${fields}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || data.error?.code !== "ok") {
|
||||||
|
throw new Error(data.error?.message || "TikTok user info fetch failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = data.data?.user ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
followers: user.follower_count ?? 0,
|
||||||
|
likes: user.likes_count ?? 0,
|
||||||
|
videoCount: user.video_count ?? 0,
|
||||||
|
username: openId,
|
||||||
|
displayName: user.display_name ?? "",
|
||||||
|
avatarUrl: user.avatar_url ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -7,7 +7,13 @@ export async function middleware(req: NextRequest) {
|
|||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
const isProtected = protectedPaths.some((p) => pathname.startsWith(p));
|
const isProtected = protectedPaths.some((p) => pathname.startsWith(p));
|
||||||
|
|
||||||
const token = await getToken({ req, secret: process.env.AUTH_SECRET });
|
const token = await getToken({
|
||||||
|
req,
|
||||||
|
secret: process.env.AUTH_SECRET,
|
||||||
|
cookieName: process.env.NODE_ENV === "production"
|
||||||
|
? "__Secure-authjs.session-token"
|
||||||
|
: "authjs.session-token"
|
||||||
|
});
|
||||||
|
|
||||||
if (isProtected && !token) {
|
if (isProtected && !token) {
|
||||||
const loginUrl = new URL("/", req.url);
|
const loginUrl = new URL("/", req.url);
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next": "^15.5.12",
|
"next": "^15.5.12",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
@@ -1545,7 +1546,6 @@
|
|||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"role" TEXT NOT NULL DEFAULT 'member',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TikTokToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"openId" TEXT NOT NULL,
|
||||||
|
"accessToken" TEXT NOT NULL,
|
||||||
|
"refreshToken" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "TikTokToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TrackedAccount" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"platform" TEXT NOT NULL,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TrackedAccount_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Snapshot" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"followers" INTEGER NOT NULL,
|
||||||
|
"views" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Snapshot_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TikTokToken_userId_key" ON "TikTokToken"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TikTokToken_openId_key" ON "TikTokToken"("openId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TikTokToken" ADD CONSTRAINT "TikTokToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TrackedAccount" ADD CONSTRAINT "TrackedAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Snapshot" ADD CONSTRAINT "Snapshot_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "TrackedAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TikTokPKCE" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"state" TEXT NOT NULL,
|
||||||
|
"codeVerifier" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TikTokPKCE_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TikTokPKCE_state_key" ON "TikTokPKCE"("state");
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `userId` to the `TikTokPKCE` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TikTokPKCE" ADD COLUMN "userId" TEXT NOT NULL;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -8,14 +8,35 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String
|
||||||
name String?
|
name String?
|
||||||
role String @default("member")
|
role String @default("member")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
accounts TrackedAccount[]
|
accounts TrackedAccount[]
|
||||||
|
tiktokToken TikTokToken?
|
||||||
|
}
|
||||||
|
|
||||||
|
model TikTokToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
openId String @unique
|
||||||
|
accessToken String
|
||||||
|
refreshToken String
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model TikTokPKCE {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
state String @unique
|
||||||
|
codeVerifier String
|
||||||
|
userId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
model TrackedAccount {
|
model TrackedAccount {
|
||||||
|
|||||||
Reference in New Issue
Block a user