Add LinkedIn OAuth connect with portal-saved app credentials.
Replace manual token paste with Authorization Code flow; store Client ID/secret in the DB from the social accounts UI and surface a copyable callback URL for LinkedIn Auth setup.
This commit is contained in:
+153
-15
@@ -1,15 +1,34 @@
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.http import JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_http_methods, require_POST
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||
|
||||
from messaging.services import parse_scheduled_for
|
||||
from social.crypto import encrypt_tokens
|
||||
from social.models import Platform, SocialAccount, SocialPost, SocialPostTarget
|
||||
from social.models import (
|
||||
Platform,
|
||||
SocialAccount,
|
||||
SocialAppCredentials,
|
||||
SocialPost,
|
||||
SocialPostTarget,
|
||||
)
|
||||
from social.oauth_linkedin import (
|
||||
LinkedInOAuthError,
|
||||
authorization_url,
|
||||
credentials_configured,
|
||||
exchange_code,
|
||||
fetch_member_profile,
|
||||
get_app_credentials,
|
||||
redirect_uri as linkedin_redirect_uri,
|
||||
token_blob_from_oauth,
|
||||
)
|
||||
from social.ollama import OllamaError, generate_social_post
|
||||
from social.tasks import publish_social_post
|
||||
|
||||
@@ -17,6 +36,11 @@ CONNECT_INSTRUCTIONS = {
|
||||
Platform.FACEBOOK: {
|
||||
"title": "Connect Facebook Page",
|
||||
"steps": [
|
||||
mark_safe(
|
||||
'Go to <a href="https://developers.facebook.com/async/registration" '
|
||||
'target="_blank" rel="noopener">Meta developer registration</a> '
|
||||
"(opens in a new tab) and fill out the information."
|
||||
),
|
||||
"Open Meta for Developers → your app → Tools → Graph API Explorer.",
|
||||
"Select your app, then Get Page Access Token for the Page you manage.",
|
||||
"Grant pages_manage_posts and pages_read_engagement (and pages_show_list).",
|
||||
@@ -49,21 +73,24 @@ CONNECT_INSTRUCTIONS = {
|
||||
},
|
||||
Platform.LINKEDIN: {
|
||||
"title": "Connect LinkedIn",
|
||||
"oauth": True,
|
||||
"steps": [
|
||||
"Create a LinkedIn Developer app and add the Share on LinkedIn / Marketing products.",
|
||||
"Generate a member or organization access token with w_member_social "
|
||||
"(or w_organization_social for company pages).",
|
||||
"Find your author URN: person URN looks like urn:li:person:XXXX; "
|
||||
"organization URN like urn:li:organization:XXXX.",
|
||||
"Paste the access token and author URN below. LinkedIn tokens expire — "
|
||||
"reconnect when publishing fails with auth errors.",
|
||||
],
|
||||
"docs_url": "https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/ugc-post-api",
|
||||
"fields": [
|
||||
("label", "Display name", "Monica Dhillon"),
|
||||
("external_id", "Author URN", "urn:li:person:…"),
|
||||
("access_token", "Access token", ""),
|
||||
mark_safe(
|
||||
'Create a LinkedIn app in the '
|
||||
'<a href="https://www.linkedin.com/developers/apps" target="_blank" '
|
||||
'rel="noopener">LinkedIn Developer Portal</a>.'
|
||||
),
|
||||
"Products tab — request “Sign In with LinkedIn using OpenID Connect” "
|
||||
"and “Share on LinkedIn”.",
|
||||
"Auth tab — under Authorized redirect URLs, paste the callback URL "
|
||||
"shown in the box below (exact match required).",
|
||||
"Auth tab — copy Client ID and Primary Client Secret into the fields below, "
|
||||
"then Save app credentials.",
|
||||
"Click Connect with LinkedIn to authorize. Access tokens last ~2 months; "
|
||||
"use Re-authorize when publishing fails with auth errors.",
|
||||
],
|
||||
"docs_url": "https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow",
|
||||
"fields": [],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -105,6 +132,30 @@ def account_list(request):
|
||||
account.save(update_fields=["is_active", "updated_at"])
|
||||
messages.success(request, f"Reactivated {account.label}.")
|
||||
return redirect("social:account_list")
|
||||
if action == "save_linkedin_app":
|
||||
client_id = (request.POST.get("client_id") or "").strip()
|
||||
client_secret = (request.POST.get("client_secret") or "").strip()
|
||||
if not client_id:
|
||||
messages.error(request, "Client ID is required.")
|
||||
return redirect(f"{request.path}?connect=linkedin")
|
||||
app, _created = SocialAppCredentials.objects.get_or_create(
|
||||
platform=Platform.LINKEDIN
|
||||
)
|
||||
app.client_id = client_id
|
||||
if client_secret:
|
||||
app.encrypted_client_secret = encrypt_tokens(client_secret)
|
||||
elif not app.encrypted_client_secret:
|
||||
messages.error(
|
||||
request,
|
||||
"Client Secret is required the first time you save LinkedIn app credentials.",
|
||||
)
|
||||
return redirect(f"{request.path}?connect=linkedin")
|
||||
app.save()
|
||||
messages.success(
|
||||
request,
|
||||
"LinkedIn app credentials saved. You can Connect with LinkedIn now.",
|
||||
)
|
||||
return redirect(f"{request.path}?connect=linkedin")
|
||||
|
||||
platform = (request.POST.get("platform") or "").strip()
|
||||
if platform not in Platform.values:
|
||||
@@ -141,6 +192,14 @@ def account_list(request):
|
||||
messages.success(request, f"{verb} {account.get_platform_display()} · {account.label}.")
|
||||
return redirect("social:account_list")
|
||||
|
||||
linkedin_app = get_app_credentials()
|
||||
linkedin_oauth_ready = credentials_configured()
|
||||
linkedin_callback = ""
|
||||
try:
|
||||
linkedin_callback = linkedin_redirect_uri()
|
||||
except LinkedInOAuthError:
|
||||
linkedin_callback = "(set PUBLIC_SITE_URL, then restart)"
|
||||
|
||||
return render(
|
||||
request,
|
||||
"social/account_list.html",
|
||||
@@ -149,10 +208,89 @@ def account_list(request):
|
||||
"connect_platform": connect_platform,
|
||||
"connect_meta": CONNECT_INSTRUCTIONS.get(connect_platform),
|
||||
"platforms": Platform,
|
||||
"linkedin_oauth_ready": linkedin_oauth_ready,
|
||||
"linkedin_callback_url": linkedin_callback,
|
||||
"linkedin_client_id": (linkedin_app.client_id if linkedin_app else ""),
|
||||
"linkedin_secret_saved": bool(
|
||||
linkedin_app and linkedin_app.encrypted_client_secret
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def linkedin_oauth_start(request):
|
||||
"""Redirect browser to LinkedIn consent screen."""
|
||||
if not credentials_configured():
|
||||
messages.error(
|
||||
request,
|
||||
"Save LinkedIn Client ID and Client Secret on the Connect LinkedIn form first.",
|
||||
)
|
||||
return redirect(f"{reverse('social:account_list')}?connect=linkedin")
|
||||
state = secrets.token_urlsafe(24)
|
||||
request.session["linkedin_oauth_state"] = state
|
||||
try:
|
||||
return redirect(authorization_url(state=state))
|
||||
except LinkedInOAuthError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect(f"{reverse('social:account_list')}?connect=linkedin")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def linkedin_oauth_callback(request):
|
||||
"""Handle LinkedIn redirect: exchange code, store encrypted SocialAccount."""
|
||||
error = (request.GET.get("error") or "").strip()
|
||||
if error:
|
||||
desc = (request.GET.get("error_description") or error).strip()
|
||||
messages.error(request, f"LinkedIn authorization denied: {desc}")
|
||||
return redirect("social:account_list")
|
||||
|
||||
state = (request.GET.get("state") or "").strip()
|
||||
expected = request.session.pop("linkedin_oauth_state", None)
|
||||
if not state or not expected or state != expected:
|
||||
messages.error(request, "LinkedIn OAuth state mismatch — try Connect again.")
|
||||
return redirect("social:account_list")
|
||||
|
||||
code = (request.GET.get("code") or "").strip()
|
||||
if not code:
|
||||
messages.error(request, "LinkedIn did not return an authorization code.")
|
||||
return redirect("social:account_list")
|
||||
|
||||
try:
|
||||
token_payload = exchange_code(code)
|
||||
profile = fetch_member_profile(token_payload["access_token"])
|
||||
author_urn = profile["author_urn"]
|
||||
blob = token_blob_from_oauth(token_payload, author_urn=author_urn)
|
||||
account, created = SocialAccount.objects.update_or_create(
|
||||
platform=Platform.LINKEDIN,
|
||||
external_id=author_urn,
|
||||
defaults={
|
||||
"label": profile["label"],
|
||||
"encrypted_tokens": encrypt_tokens(json.dumps(blob)),
|
||||
"is_active": True,
|
||||
"owner": request.user,
|
||||
},
|
||||
)
|
||||
except LinkedInOAuthError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect("social:account_list")
|
||||
except Exception:
|
||||
messages.error(
|
||||
request,
|
||||
"Unexpected error saving LinkedIn account. Check app logs.",
|
||||
)
|
||||
raise
|
||||
|
||||
verb = "Connected" if created else "Re-authorized"
|
||||
messages.success(
|
||||
request,
|
||||
f"{verb} LinkedIn · {account.label}. You can post from the composer.",
|
||||
)
|
||||
return redirect("social:account_list")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def composer(request):
|
||||
|
||||
Reference in New Issue
Block a user