Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 62 additions & 27 deletions src/app/account/account-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { createClient } from "@/lib/supabase/client";
import type { Profile } from "@/lib/db/profiles";
import type { InstagramConnection } from "@/lib/db/instagram";
import type { FacebookConnection } from "@/lib/db/facebook";
import {
REQUIRED_INSTAGRAM_SCOPES,
REQUIRED_FACEBOOK_SCOPES,
needsReauth,
} from "@/lib/core/scopes";

export default function AccountView() {
return (
Expand Down Expand Up @@ -84,13 +89,13 @@ function AccountContent() {
setCaptionStyle(p.caption_style || "polished");
setTargetAudience(p.target_audience || "");

const [{ data: ig }, { data: fb }] = await Promise.all([
const [igResult, fbResult] = await Promise.all([
supabase.from("instagram_connections").select("*").eq("profile_id", user.id).maybeSingle(),
supabase.from("facebook_connections").select("*").eq("profile_id", user.id).maybeSingle(),
]);

setInstagram(ig);
setFacebook(fb);
setInstagram(igResult.data);
setFacebook(fbResult.data);
setChecking(false);
}
load();
Expand Down Expand Up @@ -320,19 +325,34 @@ function AccountContent() {
<div className="bg-error/10 text-error rounded-lg p-3 text-sm mb-4">{igError}</div>
)}
{instagram ? (
<div className="flex items-center justify-between">
<div>
<p className="font-medium">@{instagram.instagram_username || "Connected"}</p>
<p className="text-sm text-base-content/50">
Connected {new Date(instagram.created_at).toLocaleDateString()}
</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">@{instagram.instagram_username || "Connected"}</p>
<p className="text-sm text-base-content/50">
Connected {new Date(instagram.created_at).toLocaleDateString()}
</p>
</div>
<a
href="/api/instagram/auth"
className="text-sm text-primary font-medium hover:underline"
>
Reconnect
</a>
</div>
<a
href="/api/instagram/auth"
className="text-sm text-primary font-medium hover:underline"
>
Reconnect
</a>
{needsReauth(instagram.granted_scopes, REQUIRED_INSTAGRAM_SCOPES) && (
<div className="bg-warning/10 border border-warning/30 rounded-lg p-3 flex items-center justify-between gap-3">
<p className="text-sm text-warning">
New permissions required. Please re-authorize to continue using all features.
</p>
<a
href="/api/instagram/auth"
className="shrink-0 text-sm font-medium text-warning hover:underline"
>
Re-authorize
</a>
</div>
)}
</div>
) : (
<a
Expand All @@ -350,19 +370,34 @@ function AccountContent() {
<div className="bg-error/10 text-error rounded-lg p-3 text-sm mb-4">{fbError}</div>
)}
{facebook ? (
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{facebook.page_name || "Connected"}</p>
<p className="text-sm text-base-content/50">
Connected {new Date(facebook.created_at).toLocaleDateString()}
</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{facebook.page_name || "Connected"}</p>
<p className="text-sm text-base-content/50">
Connected {new Date(facebook.created_at).toLocaleDateString()}
</p>
</div>
<a
href="/api/facebook/auth"
className="text-sm text-info font-medium hover:underline"
>
Reconnect
</a>
</div>
<a
href="/api/facebook/auth"
className="text-sm text-info font-medium hover:underline"
>
Reconnect
</a>
{needsReauth(facebook.granted_scopes, REQUIRED_FACEBOOK_SCOPES) && (
<div className="bg-warning/10 border border-warning/30 rounded-lg p-3 flex items-center justify-between gap-3">
<p className="text-sm text-warning">
New permissions required. Please re-authorize to continue using all features.
</p>
<a
href="/api/facebook/auth"
className="shrink-0 text-sm font-medium text-warning hover:underline"
>
Re-authorize
</a>
</div>
)}
</div>
) : (
<a
Expand Down
12 changes: 9 additions & 3 deletions src/app/api/facebook/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { createDbClient } from "@/lib/db/client";
import { exchangeCodeForToken } from "@/lib/facebook/auth";
import { exchangeCodeForToken, getGrantedScopes } from "@/lib/facebook/auth";
import { savePendingFacebookToken } from "@/lib/db/facebook";
import { getBaseUrl } from "@/lib/core/url";

Expand All @@ -23,15 +23,21 @@ export async function GET(request: NextRequest) {

try {
const { accessToken, userId: fbUserId } = await exchangeCodeForToken(code, baseUrl);
const grantedScopes = await getGrantedScopes(accessToken);

const db = createDbClient();

// Save token to pending table (not in URL) for page selection step
await savePendingFacebookToken(db, userId, fbUserId, accessToken);
await savePendingFacebookToken(db, userId, fbUserId, accessToken, grantedScopes ?? undefined);

return NextResponse.redirect(`${baseUrl}/account/facebook-pages`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message =
err instanceof Error
? err.message
: typeof err === "object"
? JSON.stringify(err)
: String(err);
console.error("Facebook OAuth error:", message);
return NextResponse.redirect(
`${baseUrl}/account?error=facebook_failed&detail=${encodeURIComponent(message)}`,
Expand Down
1 change: 1 addition & 0 deletions src/app/api/facebook/select-page/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export async function POST(request: NextRequest) {
facebook_page_id: page_id,
page_name: page_name || null,
page_access_token,
granted_scopes: pending.granted_scopes,
});

// Add "facebook" to publish_platforms if not already present
Expand Down
15 changes: 12 additions & 3 deletions src/app/api/instagram/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { createDbClient } from "@/lib/db/client";
import { exchangeCodeForToken, getInstagramUsername } from "@/lib/instagram/auth";
import { exchangeCodeForToken, getInstagramUsername, getGrantedScopes } from "@/lib/instagram/auth";
import { upsertInstagramConnection } from "@/lib/db/instagram";
import { getBaseUrl } from "@/lib/core/url";

Expand All @@ -25,7 +25,10 @@ export async function GET(request: NextRequest) {
try {
const { accessToken, userId: igUserId } = await exchangeCodeForToken(code, baseUrl);

const username = await getInstagramUsername(igUserId, accessToken);
const [username, grantedScopes] = await Promise.all([
getInstagramUsername(igUserId, accessToken),
getGrantedScopes(accessToken),
]);

const db = createDbClient();

Expand All @@ -36,11 +39,17 @@ export async function GET(request: NextRequest) {
access_token: accessToken,
token_expires_at: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(), // 60 days
instagram_username: username,
granted_scopes: grantedScopes,
});

return NextResponse.redirect(`${baseUrl}/account?instagram=connected`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message =
err instanceof Error
? err.message
: typeof err === "object"
? JSON.stringify(err)
: String(err);
console.error("Instagram OAuth error:", message);
return NextResponse.redirect(
`${baseUrl}/account?error=instagram_failed&detail=${encodeURIComponent(message)}`,
Expand Down
12 changes: 12 additions & 0 deletions src/lib/core/scopes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const REQUIRED_INSTAGRAM_SCOPES = [
"instagram_business_basic",
"instagram_business_content_publish",
"instagram_business_manage_insights",
];

export const REQUIRED_FACEBOOK_SCOPES = ["pages_manage_posts", "pages_read_engagement"];

export function needsReauth(grantedScopes: string[] | null, requiredScopes: string[]): boolean {
if (!grantedScopes) return true;
return requiredScopes.some((s) => !grantedScopes.includes(s));
}
11 changes: 9 additions & 2 deletions src/lib/db/facebook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export async function upsertFacebookConnection(
facebook_page_id: string;
page_name: string | null;
page_access_token: string;
granted_scopes?: string[] | null;
},
): Promise<void> {
const { error } = await client
Expand All @@ -37,12 +38,14 @@ export async function savePendingFacebookToken(
profileId: string,
facebookUserId: string,
userAccessToken: string,
grantedScopes?: string[],
): Promise<void> {
const { error } = await client.from("pending_facebook_tokens").upsert(
{
profile_id: profileId,
facebook_user_id: facebookUserId,
user_access_token: userAccessToken,
granted_scopes: grantedScopes || null,
},
{ onConflict: "profile_id" },
);
Expand All @@ -52,10 +55,14 @@ export async function savePendingFacebookToken(
export async function getPendingFacebookToken(
client: DbClient,
profileId: string,
): Promise<{ facebook_user_id: string; user_access_token: string } | null> {
): Promise<{
facebook_user_id: string;
user_access_token: string;
granted_scopes: string[] | null;
} | null> {
const { data, error } = await client
.from("pending_facebook_tokens")
.select("facebook_user_id, user_access_token")
.select("facebook_user_id, user_access_token, granted_scopes")
.eq("profile_id", profileId)
.single();
if (error) return null;
Expand Down
1 change: 1 addition & 0 deletions src/lib/db/instagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export async function upsertInstagramConnection(
access_token: string;
token_expires_at: string;
instagram_username: string | null;
granted_scopes?: string[] | null;
},
): Promise<void> {
const { error } = await client
Expand Down
27 changes: 26 additions & 1 deletion src/lib/facebook/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ export interface FacebookPage {
access_token: string;
}

import { REQUIRED_FACEBOOK_SCOPES } from "@/lib/core/scopes";

export { REQUIRED_FACEBOOK_SCOPES };

export function getFacebookAuthorizationUrl(state: string, baseUrl: string): string {
const redirectUri = `${baseUrl}/api/facebook/callback`;
const params = new URLSearchParams({
client_id: FACEBOOK_APP_ID,
redirect_uri: redirectUri,
scope: "pages_manage_posts,pages_read_engagement",
scope: REQUIRED_FACEBOOK_SCOPES.join(","),
response_type: "code",
state,
});
Expand Down Expand Up @@ -77,6 +81,27 @@ export async function exchangeCodeForToken(
};
}

export async function getGrantedScopes(accessToken: string): Promise<string[] | null> {
try {
const response = await fetch(
`https://graph.facebook.com/v21.0/me/permissions?access_token=${accessToken}`,
);
const data = await response.json();

if (data.error) {
console.error("[getGrantedScopes] Facebook permissions error:", data.error.message);
return null;
}

return (data.data || [])
.filter((p: { status: string }) => p.status === "granted")
.map((p: { permission: string }) => p.permission);
} catch (err) {
console.error("[getGrantedScopes] Facebook permissions fetch failed:", err);
return null;
}
}

export async function listPages(userAccessToken: string): Promise<FacebookPage[]> {
const response = await fetch(
`https://graph.facebook.com/v21.0/me/accounts?fields=id,name,access_token&access_token=${userAccessToken}`,
Expand Down
28 changes: 26 additions & 2 deletions src/lib/instagram/auth.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
const INSTAGRAM_APP_ID = process.env.INSTAGRAM_APP_ID!;
const INSTAGRAM_APP_SECRET = process.env.INSTAGRAM_APP_SECRET!;

import { REQUIRED_INSTAGRAM_SCOPES } from "@/lib/core/scopes";

export { REQUIRED_INSTAGRAM_SCOPES };

export function getAuthorizationUrl(state: string, baseUrl: string): string {
const redirectUri = `${baseUrl}/api/instagram/callback`;
const params = new URLSearchParams({
client_id: INSTAGRAM_APP_ID,
redirect_uri: redirectUri,
scope:
"instagram_business_basic,instagram_business_content_publish,instagram_business_manage_insights",
scope: REQUIRED_INSTAGRAM_SCOPES.join(","),
response_type: "code",
state,
});
Expand Down Expand Up @@ -112,6 +115,27 @@ export async function refreshInstagramToken(
};
}

export async function getGrantedScopes(accessToken: string): Promise<string[] | null> {
try {
const response = await fetch(
`https://graph.instagram.com/v21.0/me/permissions?access_token=${accessToken}`,
);
const data = await response.json();

if (data.error) {
console.error("[getGrantedScopes] Instagram permissions error:", data.error.message);
return null;
}

return (data.data || [])
.filter((p: { status: string }) => p.status === "granted")
.map((p: { permission: string }) => p.permission);
} catch (err) {
console.error("[getGrantedScopes] Instagram permissions fetch failed:", err);
return null;
}
}

export async function getInstagramUsername(
userId: string,
accessToken: string,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/supabase/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface InstagramConnection {
access_token: string;
token_expires_at: string | null;
instagram_username: string | null;
granted_scopes: string[] | null;
created_at: string;
updated_at: string;
}
Expand All @@ -30,6 +31,7 @@ export interface FacebookConnection {
facebook_page_id: string;
page_name: string | null;
page_access_token: string;
granted_scopes: string[] | null;
created_at: string;
updated_at: string;
}
Expand Down
11 changes: 11 additions & 0 deletions supabase/migrations/013_add_granted_scopes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Add granted_scopes to track which OAuth scopes each connection was authorized with.
-- NULL means "unknown / pre-migration" — treat as needing re-auth when new scopes are required.

ALTER TABLE instagram_connections
ADD COLUMN IF NOT EXISTS granted_scopes text[];

ALTER TABLE facebook_connections
ADD COLUMN IF NOT EXISTS granted_scopes text[];

ALTER TABLE pending_facebook_tokens
ADD COLUMN IF NOT EXISTS granted_scopes text[];