diff --git a/src/app/account/account-view.tsx b/src/app/account/account-view.tsx index ab964af..410a230 100644 --- a/src/app/account/account-view.tsx +++ b/src/app/account/account-view.tsx @@ -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 ( @@ -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(); @@ -320,19 +325,34 @@ function AccountContent() {
{igError}
)} {instagram ? ( -
-
-

@{instagram.instagram_username || "Connected"}

-

- Connected {new Date(instagram.created_at).toLocaleDateString()} -

+
+
+
+

@{instagram.instagram_username || "Connected"}

+

+ Connected {new Date(instagram.created_at).toLocaleDateString()} +

+
+ + Reconnect +
- - Reconnect - + {needsReauth(instagram.granted_scopes, REQUIRED_INSTAGRAM_SCOPES) && ( +
+

+ New permissions required. Please re-authorize to continue using all features. +

+ + Re-authorize + +
+ )}
) : ( {fbError}
)} {facebook ? ( -
-
-

{facebook.page_name || "Connected"}

-

- Connected {new Date(facebook.created_at).toLocaleDateString()} -

+
+
+
+

{facebook.page_name || "Connected"}

+

+ Connected {new Date(facebook.created_at).toLocaleDateString()} +

+
+
+ Reconnect +
- - Reconnect - + {needsReauth(facebook.granted_scopes, REQUIRED_FACEBOOK_SCOPES) && ( +
+

+ New permissions required. Please re-authorize to continue using all features. +

+ + Re-authorize + +
+ )}
) : ( !grantedScopes.includes(s)); +} diff --git a/src/lib/db/facebook.ts b/src/lib/db/facebook.ts index 331855a..68dbfae 100644 --- a/src/lib/db/facebook.ts +++ b/src/lib/db/facebook.ts @@ -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 { const { error } = await client @@ -37,12 +38,14 @@ export async function savePendingFacebookToken( profileId: string, facebookUserId: string, userAccessToken: string, + grantedScopes?: string[], ): Promise { 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" }, ); @@ -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; diff --git a/src/lib/db/instagram.ts b/src/lib/db/instagram.ts index 95d71f0..39b8257 100644 --- a/src/lib/db/instagram.ts +++ b/src/lib/db/instagram.ts @@ -40,6 +40,7 @@ export async function upsertInstagramConnection( access_token: string; token_expires_at: string; instagram_username: string | null; + granted_scopes?: string[] | null; }, ): Promise { const { error } = await client diff --git a/src/lib/facebook/auth.ts b/src/lib/facebook/auth.ts index eb4da3f..2fc51e9 100644 --- a/src/lib/facebook/auth.ts +++ b/src/lib/facebook/auth.ts @@ -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, }); @@ -77,6 +81,27 @@ export async function exchangeCodeForToken( }; } +export async function getGrantedScopes(accessToken: string): Promise { + 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 { const response = await fetch( `https://graph.facebook.com/v21.0/me/accounts?fields=id,name,access_token&access_token=${userAccessToken}`, diff --git a/src/lib/instagram/auth.ts b/src/lib/instagram/auth.ts index 9515c24..7f095de 100644 --- a/src/lib/instagram/auth.ts +++ b/src/lib/instagram/auth.ts @@ -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, }); @@ -112,6 +115,27 @@ export async function refreshInstagramToken( }; } +export async function getGrantedScopes(accessToken: string): Promise { + 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, diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index dc37063..affac58 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -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; } @@ -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; } diff --git a/supabase/migrations/013_add_granted_scopes.sql b/supabase/migrations/013_add_granted_scopes.sql new file mode 100644 index 0000000..5518378 --- /dev/null +++ b/supabase/migrations/013_add_granted_scopes.sql @@ -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[];