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:
@@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from social.models import SocialAccount, SocialPost, SocialPostTarget
|
||||
from social.models import SocialAccount, SocialAppCredentials, SocialPost, SocialPostTarget
|
||||
|
||||
|
||||
class SocialPostTargetInline(admin.TabularInline):
|
||||
@@ -8,6 +8,12 @@ class SocialPostTargetInline(admin.TabularInline):
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(SocialAppCredentials)
|
||||
class SocialAppCredentialsAdmin(admin.ModelAdmin):
|
||||
list_display = ("platform", "client_id", "updated_at")
|
||||
readonly_fields = ("encrypted_client_secret", "created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(SocialAccount)
|
||||
class SocialAccountAdmin(admin.ModelAdmin):
|
||||
list_display = ("label", "platform", "is_active", "external_id")
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Generated manually for SocialAppCredentials
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("social", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SocialAppCredentials",
|
||||
fields=[
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"platform",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("facebook", "Facebook"),
|
||||
("instagram", "Instagram"),
|
||||
("linkedin", "LinkedIn"),
|
||||
],
|
||||
max_length=16,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("client_id", models.CharField(blank=True, max_length=255)),
|
||||
("encrypted_client_secret", models.TextField(blank=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name_plural": "social app credentials",
|
||||
"ordering": ["platform"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -10,6 +10,29 @@ class Platform(models.TextChoices):
|
||||
LINKEDIN = "linkedin", "LinkedIn"
|
||||
|
||||
|
||||
class SocialAppCredentials(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
"""
|
||||
Developer-app OAuth credentials entered in the portal (not env).
|
||||
|
||||
client_secret is Fernet-encrypted. One row per platform.
|
||||
"""
|
||||
|
||||
platform = models.CharField(max_length=16, choices=Platform.choices, unique=True)
|
||||
client_id = models.CharField(max_length=255, blank=True)
|
||||
encrypted_client_secret = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "social app credentials"
|
||||
ordering = ["platform"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.platform} app credentials"
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.client_id and self.encrypted_client_secret)
|
||||
|
||||
|
||||
class SocialAccount(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
platform = models.CharField(max_length=16, choices=Platform.choices)
|
||||
label = models.CharField(max_length=120)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""LinkedIn 3-legged OAuth helpers (Authorization Code flow)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from social.crypto import decrypt_tokens
|
||||
from social.models import Platform, SocialAppCredentials
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUTHORIZE_URL = "https://www.linkedin.com/oauth/v2/authorization"
|
||||
TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"
|
||||
USERINFO_URL = "https://api.linkedin.com/v2/userinfo"
|
||||
ME_URL = "https://api.linkedin.com/v2/me"
|
||||
|
||||
# openid/profile/email: Sign In with LinkedIn (OpenID Connect)
|
||||
# w_member_social: Share on LinkedIn product (required to post as the member)
|
||||
DEFAULT_SCOPES = ("openid", "profile", "email", "w_member_social")
|
||||
|
||||
|
||||
class LinkedInOAuthError(RuntimeError):
|
||||
"""Raised when LinkedIn OAuth or profile lookup fails."""
|
||||
|
||||
|
||||
def get_app_credentials() -> SocialAppCredentials | None:
|
||||
return SocialAppCredentials.objects.filter(platform=Platform.LINKEDIN).first()
|
||||
|
||||
|
||||
def load_client_credentials() -> tuple[str, str]:
|
||||
"""Return (client_id, client_secret) from portal-saved credentials."""
|
||||
app = get_app_credentials()
|
||||
if not app or not app.client_id or not app.encrypted_client_secret:
|
||||
raise LinkedInOAuthError(
|
||||
"LinkedIn app credentials are not saved yet. "
|
||||
"Enter Client ID and Client Secret on the Connect LinkedIn form."
|
||||
)
|
||||
try:
|
||||
secret = decrypt_tokens(app.encrypted_client_secret)
|
||||
except ValueError as exc:
|
||||
raise LinkedInOAuthError(
|
||||
"Could not decrypt LinkedIn client secret. "
|
||||
"Re-enter the Client Secret on the Connect LinkedIn form."
|
||||
) from exc
|
||||
if not secret:
|
||||
raise LinkedInOAuthError("LinkedIn client secret is empty — save it again.")
|
||||
return app.client_id.strip(), secret.strip()
|
||||
|
||||
|
||||
def credentials_configured() -> bool:
|
||||
app = get_app_credentials()
|
||||
return bool(app and app.is_configured)
|
||||
|
||||
|
||||
def redirect_uri() -> str:
|
||||
"""Absolute callback URL — must match Authorized redirect URLs in LinkedIn Auth tab."""
|
||||
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
|
||||
if not base:
|
||||
raise LinkedInOAuthError(
|
||||
"PUBLIC_SITE_URL is not set; cannot build LinkedIn OAuth redirect URI."
|
||||
)
|
||||
return f"{base}{reverse('social:linkedin_oauth_callback')}"
|
||||
|
||||
|
||||
def authorization_url(*, state: str, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> str:
|
||||
client_id, _secret = load_client_credentials()
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri(),
|
||||
"state": state,
|
||||
"scope": " ".join(scopes),
|
||||
}
|
||||
return f"{AUTHORIZE_URL}?{urlencode(params)}"
|
||||
|
||||
|
||||
def exchange_code(code: str) -> dict:
|
||||
"""Exchange authorization code for access token payload."""
|
||||
client_id, client_secret = load_client_credentials()
|
||||
response = requests.post(
|
||||
TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri(),
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
logger.warning(
|
||||
"LinkedIn token exchange failed: %s %s",
|
||||
response.status_code,
|
||||
response.text[:500],
|
||||
)
|
||||
raise LinkedInOAuthError(
|
||||
f"LinkedIn token exchange failed ({response.status_code}). "
|
||||
"Check client ID/secret, redirect URI, and that Share on LinkedIn is enabled."
|
||||
)
|
||||
payload = response.json()
|
||||
if not payload.get("access_token"):
|
||||
raise LinkedInOAuthError("LinkedIn token response missing access_token.")
|
||||
return payload
|
||||
|
||||
|
||||
def fetch_member_profile(access_token: str) -> dict:
|
||||
"""
|
||||
Resolve member display name + person URN.
|
||||
|
||||
Prefers OpenID userinfo (`sub`); falls back to legacy /v2/me.
|
||||
"""
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
userinfo = requests.get(USERINFO_URL, headers=headers, timeout=30)
|
||||
if userinfo.status_code == 200:
|
||||
data = userinfo.json()
|
||||
sub = (data.get("sub") or "").strip()
|
||||
if sub:
|
||||
name = (
|
||||
(data.get("name") or "").strip()
|
||||
or " ".join(
|
||||
p
|
||||
for p in (
|
||||
(data.get("given_name") or "").strip(),
|
||||
(data.get("family_name") or "").strip(),
|
||||
)
|
||||
if p
|
||||
)
|
||||
or "LinkedIn member"
|
||||
)
|
||||
return {
|
||||
"author_urn": f"urn:li:person:{sub}",
|
||||
"label": name,
|
||||
"email": (data.get("email") or "").strip(),
|
||||
}
|
||||
|
||||
me = requests.get(
|
||||
ME_URL,
|
||||
headers={
|
||||
**headers,
|
||||
"X-Restli-Protocol-Version": "2.0.0",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if me.status_code >= 400:
|
||||
logger.warning(
|
||||
"LinkedIn profile lookup failed: userinfo=%s me=%s %s",
|
||||
userinfo.status_code,
|
||||
me.status_code,
|
||||
me.text[:500],
|
||||
)
|
||||
raise LinkedInOAuthError(
|
||||
"Could not load LinkedIn profile. Ensure Sign In with LinkedIn "
|
||||
"(openid/profile) is approved on the app."
|
||||
)
|
||||
data = me.json()
|
||||
person_id = (data.get("id") or "").strip()
|
||||
if not person_id:
|
||||
raise LinkedInOAuthError("LinkedIn /v2/me response missing id.")
|
||||
localized = data.get("localizedFirstName") or ""
|
||||
last = data.get("localizedLastName") or ""
|
||||
label = f"{localized} {last}".strip() or "LinkedIn member"
|
||||
return {"author_urn": f"urn:li:person:{person_id}", "label": label, "email": ""}
|
||||
|
||||
|
||||
def token_blob_from_oauth(token_payload: dict, *, author_urn: str) -> dict:
|
||||
expires_in = int(token_payload.get("expires_in") or 0)
|
||||
blob: dict = {
|
||||
"access_token": token_payload["access_token"],
|
||||
"author_urn": author_urn,
|
||||
"token_type": token_payload.get("token_type") or "Bearer",
|
||||
"scope": token_payload.get("scope") or "",
|
||||
}
|
||||
if token_payload.get("refresh_token"):
|
||||
blob["refresh_token"] = token_payload["refresh_token"]
|
||||
if expires_in:
|
||||
blob["expires_at"] = (
|
||||
timezone.now() + timedelta(seconds=expires_in)
|
||||
).isoformat()
|
||||
return blob
|
||||
@@ -27,7 +27,7 @@
|
||||
<span class="platform-icon li">in</span>
|
||||
<div>
|
||||
<strong>LinkedIn</strong>
|
||||
<p>Personal or organization page. Tokens expire — reconnect when prompted.</p>
|
||||
<p>Connect via OAuth. Tokens expire (~2 months) — re-authorize when prompted.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -43,6 +43,55 @@
|
||||
<li>Reference: <a href="{{ connect_meta.docs_url }}" target="_blank" rel="noopener">platform docs</a></li>
|
||||
{% endif %}
|
||||
</ol>
|
||||
|
||||
{% if connect_meta.oauth and connect_platform == 'linkedin' %}
|
||||
<div class="field" style="margin:12px 0 16px;padding:12px;border:1px dashed #cbd5e1;border-radius:8px;background:#f8fafc">
|
||||
<label for="linkedin-callback-url" style="display:block;margin-bottom:6px">
|
||||
Authorized redirect URL — paste into LinkedIn Auth tab
|
||||
</label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:stretch">
|
||||
<input id="linkedin-callback-url" type="text" readonly
|
||||
value="{{ linkedin_callback_url }}"
|
||||
onclick="this.select()"
|
||||
style="flex:1;min-width:220px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px">
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="copy-linkedin-callback">Copy</button>
|
||||
</div>
|
||||
<p class="muted" style="margin:8px 0 0;font-size:13px">
|
||||
Must match exactly (including http/https and trailing slash).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post" class="form-grid" style="margin-bottom:16px">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="save_linkedin_app">
|
||||
<div class="field">
|
||||
<label for="id_client_id">Client ID</label>
|
||||
<input id="id_client_id" name="client_id" required
|
||||
value="{{ linkedin_client_id }}"
|
||||
placeholder="From LinkedIn Auth tab"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_client_secret">Primary Client Secret</label>
|
||||
<input id="id_client_secret" name="client_secret" type="password"
|
||||
{% if not linkedin_secret_saved %}required{% endif %}
|
||||
placeholder="{% if linkedin_secret_saved %}Leave blank to keep saved secret{% else %}From LinkedIn Auth tab{% endif %}"
|
||||
autocomplete="new-password">
|
||||
{% if linkedin_secret_saved %}
|
||||
<p class="muted" style="margin:6px 0 0;font-size:13px">Secret already saved (encrypted). Enter a new value only to replace it.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-ghost" type="submit">Save app credentials</button>
|
||||
{% if linkedin_oauth_ready %}
|
||||
<a class="btn btn-primary" href="{% url 'social:linkedin_oauth_start' %}">Connect with LinkedIn</a>
|
||||
{% else %}
|
||||
<button class="btn btn-primary" type="button" disabled title="Save Client ID and Client Secret first">Connect with LinkedIn</button>
|
||||
{% endif %}
|
||||
<a class="btn btn-ghost" href="{% url 'social:account_list' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" class="form-grid">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="connect">
|
||||
@@ -65,6 +114,7 @@
|
||||
<a class="btn btn-ghost" href="{% url 'social:account_list' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -103,7 +153,11 @@
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
{% if account.platform == 'linkedin' %}
|
||||
<a class="btn btn-ghost btn-sm" href="{% url 'social:linkedin_oauth_start' %}">Re-authorize</a>
|
||||
{% else %}
|
||||
<a class="btn btn-ghost btn-sm" href="{% url 'social:account_list' %}?connect={{ account.platform }}">Update tokens</a>
|
||||
{% endif %}
|
||||
{% if account.is_active %}
|
||||
<form method="post" style="display:inline">
|
||||
{% csrf_token %}
|
||||
@@ -155,4 +209,25 @@
|
||||
document.getElementById('connect-form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if connect_platform == 'linkedin' %}
|
||||
<script>
|
||||
(function () {
|
||||
const btn = document.getElementById('copy-linkedin-callback');
|
||||
const input = document.getElementById('linkedin-callback-url');
|
||||
if (!btn || !input) return;
|
||||
btn.addEventListener('click', async () => {
|
||||
const value = input.value || '';
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
const prev = btn.textContent;
|
||||
btn.textContent = 'Copied';
|
||||
setTimeout(() => { btn.textContent = prev; }, 1500);
|
||||
} catch (_err) {
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,6 +7,16 @@ app_name = "social"
|
||||
urlpatterns = [
|
||||
path("", views.post_list, name="post_list"),
|
||||
path("accounts/", views.account_list, name="account_list"),
|
||||
path(
|
||||
"accounts/linkedin/start/",
|
||||
views.linkedin_oauth_start,
|
||||
name="linkedin_oauth_start",
|
||||
),
|
||||
path(
|
||||
"accounts/linkedin/callback/",
|
||||
views.linkedin_oauth_callback,
|
||||
name="linkedin_oauth_callback",
|
||||
),
|
||||
path("compose/", views.composer, name="composer"),
|
||||
path("api/generate/", views.api_generate, name="api_generate"),
|
||||
path("<uuid:pk>/", views.post_detail, name="post_detail"),
|
||||
|
||||
+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