feat: add the tiktok integration
This commit is contained in:
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 { Music2 } from "lucide-react";
|
||||
|
||||
interface TikTokStats {
|
||||
followers: number;
|
||||
likes: number;
|
||||
videoCount: number;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="p-2 rounded-sm bg-pink-500/10 border border-pink-500/20">
|
||||
<Music2 size={18} className="text-pink-400" />
|
||||
</div>
|
||||
<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>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-sm bg-pink-500/10 border border-pink-500/20">
|
||||
<Music2 size={18} className="text-pink-400" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{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 className="grid grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard label="Followers" value="—" sub="Aucune donnée" accent="red" />
|
||||
<StatCard label="Likes totaux" value="—" sub="Aucune donnée" accent="red" />
|
||||
<StatCard label="Vues totales" value="—" sub="Aucune donnée" accent="red" />
|
||||
<StatCard label="Vidéos publiées" value="—" sub="Aucune donnée" accent="red" />
|
||||
</div>
|
||||
{/* Erreur URL */}
|
||||
{urlError && (
|
||||
<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">
|
||||
<AlertTriangle size={14} />
|
||||
{urlError === "access_denied" && "Accès refusé par TikTok."}
|
||||
{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]">
|
||||
<Music2 size={32} className="text-pink-400/20" />
|
||||
<p className="text-[#3a5a3a] font-mono text-xs tracking-widest text-center">
|
||||
CONNECTEZ VOTRE COMPTE TIKTOK<br />POUR AFFICHER VOS STATISTIQUES
|
||||
</p>
|
||||
</div>
|
||||
{/* Chargement */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center min-h-[200px] gap-3 text-[#3a5a3a] font-mono text-xs">
|
||||
<Loader2 size={16} className="animate-spin text-pink-400/40" />
|
||||
Chargement...
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user