feat/TikTok-graph #1

Merged
Pierre1901 merged 6 commits from feat/TikTok-graph into main 2026-03-17 11:39:14 +01:00
6 changed files with 48 additions and 19 deletions
Showing only changes of commit 0ce9c1be39 - Show all commits

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ dist/
build/ build/
/app/generated/prisma /app/generated/prisma
docker-compose.yml docker-compose.yml
deploy.sh

View File

@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
import { exchangeCodeForTokens } from "@/lib/tiktok"; import { exchangeCodeForTokens } from "@/lib/tiktok";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
const baseUrl = process.env.NEXTAUTH_URL || "https://marouette.fun";
BoxOfPandor marked this conversation as resolved Outdated

need to be carfull to not put an domain in plaintext and use a throw error if the varible is not load

need to be carfull to not put an domain in plaintext and use a throw error if the varible is not load
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const code = searchParams.get("code"); const code = searchParams.get("code");
@@ -9,14 +11,13 @@ export async function GET(request: NextRequest) {
const error = searchParams.get("error"); const error = searchParams.get("error");
if (error || !code || !state) { if (error || !code || !state) {
return NextResponse.redirect(new URL("/tiktok?error=access_denied", request.url)); return NextResponse.redirect(`${baseUrl}/tiktok?error=access_denied`);
} }
try { try {
// userId stocké dans le record PKCE lors du /connect (pas besoin de session)
const pkceRecord = await prisma.tikTokPKCE.findUnique({ where: { state } }); const pkceRecord = await prisma.tikTokPKCE.findUnique({ where: { state } });
if (!pkceRecord) { if (!pkceRecord) {
return NextResponse.redirect(new URL("/tiktok?error=missing_verifier", request.url)); return NextResponse.redirect(`${baseUrl}/tiktok?error=missing_verifier`);
} }
await prisma.tikTokPKCE.delete({ where: { state } }); await prisma.tikTokPKCE.delete({ where: { state } });
@@ -29,23 +30,48 @@ export async function GET(request: NextRequest) {
const tokens = await exchangeCodeForTokens(code, codeVerifier); const tokens = await exchangeCodeForTokens(code, codeVerifier);
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
// Upsert sur userId — plusieurs users peuvent partager le même compte TikTok
await prisma.tikTokToken.upsert({ await prisma.tikTokToken.upsert({
where: { userId }, where: { userId },
create: { userId, openId: tokens.open_id, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt }, update: {
update: { openId: tokens.open_id, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt }, openId: tokens.open_id,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt,
},
create: {
userId,
openId: tokens.open_id,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt,
},
}); });
const existing = await prisma.trackedAccount.findFirst({ where: { userId, platform: "tiktok" } }); // TrackedAccount : update si existe, sinon create
if (!existing) { const existing = await prisma.trackedAccount.findFirst({
where: { userId, platform: "tiktok" },
});
if (existing) {
await prisma.trackedAccount.update({
where: { id: existing.id },
data: { username: tokens.open_id, accountId: tokens.open_id },
});
} else {
await prisma.trackedAccount.create({ await prisma.trackedAccount.create({
data: { userId, platform: "tiktok", username: tokens.open_id, accountId: tokens.open_id }, data: {
userId,
platform: "tiktok",
username: tokens.open_id,
accountId: tokens.open_id,
},
}); });
} }
return NextResponse.redirect(new URL("/tiktok?connected=1", request.url)); return NextResponse.redirect(`${baseUrl}/tiktok?connected=1`);
} catch (err) { } catch (err) {
console.error("[TikTok callback error]", err); console.error("[TikTok callback error]", err);
return NextResponse.redirect(new URL("/tiktok?error=token_exchange", request.url)); return NextResponse.redirect(`${baseUrl}/tiktok?error=token_exchange`);
} }
} }

View File

@@ -31,6 +31,7 @@ services:
- "3001:3001" - "3001:3001"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- AUTH_URL=${AUTH_URL}
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${DATABASE_URL}
- NEXTAUTH_URL=${NEXTAUTH_URL} - NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET} - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}

View File

@@ -43,6 +43,10 @@ export function getTikTokAuthUrl(state: string, codeChallenge: string): string {
state, state,
code_challenge: codeChallenge, code_challenge: codeChallenge,
code_challenge_method: "S256", code_challenge_method: "S256",
// "select_account" seul est ignoré par TikTok si une session est active.
// On passe les deux pour forcer l'affichage du sélecteur de compte à chaque fois.
prompt: "select_account",
force_login: "true",
}); });
return `${TIKTOK_AUTH_URL}?${params.toString()}`; return `${TIKTOK_AUTH_URL}?${params.toString()}`;
} }
@@ -142,9 +146,4 @@ export async function fetchUserStats(accessToken: string, openId: string): Promi
displayName: user.display_name ?? "", displayName: user.display_name ?? "",
avatarUrl: user.avatar_url ?? "", avatarUrl: user.avatar_url ?? "",
}; };
} }

View File

@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "TikTokToken_openId_key";

View File

@@ -23,7 +23,7 @@ model TikTokToken {
id String @id @default(cuid()) id String @id @default(cuid())
userId String @unique userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
openId String @unique openId String
accessToken String accessToken String
refreshToken String refreshToken String
expiresAt DateTime expiresAt DateTime