Add Meta Facebook Login OAuth and social media attach/publish.
Deploy Beta / unit-tests (push) Successful in 14s
Deploy Beta / docker (push) Successful in 17s
Deploy Beta / deploy-beta (push) Successful in 1m32s

Connect Facebook and Instagram via shared portal Meta App credentials (Facebook Login for Business), and let compose attach DB-stored images/videos with live preview and platform publish.
This commit is contained in:
2026-08-11 14:45:23 -05:00
parent c8dacd0d94
commit 30ce58e610
13 changed files with 1412 additions and 142 deletions
+29 -24
View File
@@ -11,15 +11,15 @@
<span class="platform-icon meta">f</span>
<div>
<strong>Facebook Page</strong>
<p>Connect a Page you manage with a Page access token.</p>
<p>Connect via Facebook Login for Business. Shared Meta App ID with Instagram.</p>
</div>
</a>
<a class="connect-card{% if connect_platform == 'instagram' %} is-active{% endif %}"
href="{% url 'social:account_list' %}?connect=instagram">
<span class="platform-icon ig">IG</span>
<div>
<strong>Instagram Business</strong>
<p>Requires a Facebook Page linked to an IG business account.</p>
<strong>Instagram</strong>
<p>Facebook Login for Business — Professional account linked to a Page.</p>
</div>
</a>
<a class="connect-card{% if connect_platform == 'linkedin' %} is-active{% endif %}"
@@ -44,17 +44,17 @@
{% endif %}
</ol>
{% if connect_meta.oauth and connect_platform == 'linkedin' %}
{% if connect_meta.oauth %}
<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 for="oauth-callback-url" style="display:block;margin-bottom:6px">
{{ connect_meta.callback_label }}
</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 }}"
<input id="oauth-callback-url" type="text" readonly
value="{{ oauth_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>
<button type="button" class="btn btn-ghost btn-sm" id="copy-oauth-callback">Copy</button>
</div>
<p class="muted" style="margin:8px 0 0;font-size:13px">
Must match exactly (including http/https and trailing slash).
@@ -63,30 +63,31 @@
<form method="post" class="form-grid" style="margin-bottom:16px">
{% csrf_token %}
<input type="hidden" name="action" value="save_linkedin_app">
<input type="hidden" name="action" value="{{ connect_meta.save_action }}">
<input type="hidden" name="connect_return" value="{{ connect_platform }}">
<div class="field">
<label for="id_client_id">Client ID</label>
<label for="id_client_id">{{ connect_meta.client_id_label }}</label>
<input id="id_client_id" name="client_id" required
value="{{ linkedin_client_id }}"
placeholder="From LinkedIn Auth tab"
value="{{ oauth_client_id }}"
placeholder="From the developer portal"
autocomplete="off">
</div>
<div class="field">
<label for="id_client_secret">Primary Client Secret</label>
<label for="id_client_secret">{{ connect_meta.client_secret_label }}</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 %}"
{% if not oauth_secret_saved %}required{% endif %}
placeholder="{% if oauth_secret_saved %}Leave blank to keep saved secret{% else %}From the developer portal{% endif %}"
autocomplete="new-password">
{% if linkedin_secret_saved %}
{% if oauth_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>
{% if oauth_ready and oauth_start_url_name %}
<a class="btn btn-primary" href="{% url oauth_start_url_name %}">{{ connect_meta.connect_label }}</a>
{% else %}
<button class="btn btn-primary" type="button" disabled title="Save Client ID and Client Secret first">Connect with LinkedIn</button>
<button class="btn btn-primary" type="button" disabled title="Save App ID and App Secret first">{{ connect_meta.connect_label }}</button>
{% endif %}
<a class="btn btn-ghost" href="{% url 'social:account_list' %}">Cancel</a>
</div>
@@ -155,6 +156,10 @@
<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>
{% elif account.platform == 'instagram' %}
<a class="btn btn-ghost btn-sm" href="{% url 'social:instagram_oauth_start' %}">Re-authorize</a>
{% elif account.platform == 'facebook' %}
<a class="btn btn-ghost btn-sm" href="{% url 'social:facebook_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 %}
@@ -189,7 +194,7 @@
<div class="panel-h"><h2>When to re-auth</h2></div>
<div class="panel-b">
<ul class="plain-list">
<li>LinkedIn access tokens expire on a short cycle</li>
<li>LinkedIn / Instagram access tokens expire on a short cycle</li>
<li>Meta password changes or app review updates</li>
<li>Publishing fails with an auth error</li>
</ul>
@@ -209,11 +214,11 @@
document.getElementById('connect-form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
</script>
{% endif %}
{% if connect_platform == 'linkedin' %}
{% if connect_meta and connect_meta.oauth %}
<script>
(function () {
const btn = document.getElementById('copy-linkedin-callback');
const input = document.getElementById('linkedin-callback-url');
const btn = document.getElementById('copy-oauth-callback');
const input = document.getElementById('oauth-callback-url');
if (!btn || !input) return;
btn.addEventListener('click', async () => {
const value = input.value || '';
+272 -20
View File
@@ -1,6 +1,79 @@
{% extends "portal_base.html" %}
{% block title %}Compose · Social{% endblock %}
{% block topbar_title %}Compose & preview{% endblock %}
{% block extra_head %}
<style>
.media-drop {
border: 1px dashed var(--monica-border, #cbd5e1);
border-radius: 8px;
padding: 14px;
background: #f8fafc;
}
.media-thumbs {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.media-thumb {
position: relative;
width: 96px;
height: 96px;
border-radius: 8px;
overflow: hidden;
background: #e2e8f0;
border: 1px solid var(--monica-border, #cbd5e1);
}
.media-thumb img,
.media-thumb video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.media-thumb .remove-media {
position: absolute;
top: 4px;
right: 4px;
border: 0;
border-radius: 999px;
width: 22px;
height: 22px;
line-height: 22px;
padding: 0;
background: rgba(15, 23, 42, 0.75);
color: #fff;
cursor: pointer;
font-size: 14px;
}
.media-thumb .media-label {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.65);
color: #fff;
font-size: 10px;
padding: 2px 4px;
text-align: center;
}
#preview-media {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
#preview-media img,
#preview-media video {
max-width: 100%;
max-height: 280px;
border-radius: 8px;
background: #0f172a;
}
#preview-media img { object-fit: contain; }
#media-upload-status { margin-top: 8px; font-size: 13px; }
</style>
{% endblock %}
{% block portal_content %}
{% if not accounts %}
<div class="auth-alert">
@@ -21,18 +94,32 @@
{% endif %}
<form method="post" class="form-grid" id="social-compose">
{% csrf_token %}
<input type="hidden" name="media_json" id="id_media_json" value="{{ form.media_json }}">
<div class="field">
<label for="id_body">Caption</label>
<textarea id="id_body" name="body" style="min-height:140px"
placeholder="Open house this Saturday…"
oninput="syncPreview()">{{ form.body }}</textarea>
placeholder="Open house this Saturday…">{{ form.body }}</textarea>
<div class="hint"><span id="char-count">0</span> characters · IG soft limit ~2,200</div>
</div>
<div class="field">
<label for="id_media">Media</label>
<div class="media-drop">
<input id="id_media" type="file" accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/quicktime,video/webm" multiple>
<div class="hint" style="margin-top:8px">
Images (JPEG/PNG/GIF/WebP, ≤8&nbsp;MB) or one video (MP4/MOV/WebM, ≤100&nbsp;MB).
Instagram requires media. Dont mix images + video on one post.
</div>
<div id="media-upload-status" class="muted" hidden></div>
<div class="media-thumbs" id="media-thumbs"></div>
</div>
</div>
<div class="field">
<label>Accounts</label>
{% for account in accounts %}
<label class="check-row">
<input type="checkbox" name="account_ids" value="{{ account.pk }}"
class="account-check"
data-platform="{{ account.platform }}"
{% if account.pk|stringformat:"s" in form.account_ids %}checked{% endif %}>
{{ account.get_platform_display }} · {{ account.label }}
</label>
@@ -70,6 +157,7 @@
<input type="hidden" name="action" value="generate">
<input type="hidden" name="publish_mode" value="{{ form.publish_mode }}">
<input type="hidden" name="scheduled_for" value="{{ form.scheduled_for }}">
<input type="hidden" name="media_json" value="{{ form.media_json }}">
{% for id in form.account_ids %}
<input type="hidden" name="account_ids" value="{{ id }}">
{% endfor %}
@@ -89,8 +177,11 @@
<div class="panel-h"><h2>Preview</h2></div>
<div class="panel-b">
<div class="preview-pane">
<div class="muted" id="preview-empty">Preview updates as you type.</div>
<div id="preview-content" hidden style="white-space:pre-wrap"></div>
<div class="muted" id="preview-empty">Preview updates as you type or attach media.</div>
<div id="preview-body" hidden>
<div id="preview-media"></div>
<div id="preview-content" style="white-space:pre-wrap"></div>
</div>
</div>
</div>
</div>
@@ -102,29 +193,190 @@
{% block extra_js %}
<script>
(function () {
function syncPreview() {
var body = (document.getElementById('id_body') || {}).value || '';
var count = document.getElementById('char-count');
if (count) count.textContent = String(body.length);
var empty = document.getElementById('preview-empty');
var content = document.getElementById('preview-content');
if (!empty || !content) return;
if (!body) {
empty.hidden = false;
content.hidden = true;
return;
}
empty.hidden = true;
content.hidden = false;
content.textContent = body;
}
var bodyEl = document.getElementById('id_body');
var countEl = document.getElementById('char-count');
var empty = document.getElementById('preview-empty');
var previewBody = document.getElementById('preview-body');
var content = document.getElementById('preview-content');
var previewMedia = document.getElementById('preview-media');
var mode = document.getElementById('id_publish_mode');
var when = document.getElementById('id_scheduled_for');
var mediaInput = document.getElementById('id_media');
var mediaJson = document.getElementById('id_media_json');
var thumbs = document.getElementById('media-thumbs');
var statusEl = document.getElementById('media-upload-status');
var uploadUrl = "{{ media_upload_url|escapejs }}";
var csrf = (document.querySelector('#social-compose input[name=csrfmiddlewaretoken]') || {}).value || '';
var mediaItems = [];
try {
mediaItems = JSON.parse((mediaJson && mediaJson.value) || '[]') || [];
if (!Array.isArray(mediaItems)) mediaItems = [];
} catch (_err) {
mediaItems = [];
}
function setStatus(msg, isError) {
if (!statusEl) return;
if (!msg) {
statusEl.hidden = true;
statusEl.textContent = '';
return;
}
statusEl.hidden = false;
statusEl.textContent = msg;
statusEl.style.color = isError ? '#b91c1c' : '';
}
function syncMediaField() {
if (mediaJson) mediaJson.value = JSON.stringify(mediaItems);
}
function renderThumbs() {
if (!thumbs) return;
thumbs.innerHTML = '';
mediaItems.forEach(function (item, idx) {
var wrap = document.createElement('div');
wrap.className = 'media-thumb';
if (item.type === 'video') {
var vid = document.createElement('video');
vid.src = item.url;
vid.muted = true;
wrap.appendChild(vid);
var label = document.createElement('div');
label.className = 'media-label';
label.textContent = 'Video';
wrap.appendChild(label);
} else {
var img = document.createElement('img');
img.src = item.url;
img.alt = item.filename || 'Image';
wrap.appendChild(img);
}
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'remove-media';
btn.setAttribute('aria-label', 'Remove');
btn.textContent = '×';
btn.addEventListener('click', function () {
mediaItems.splice(idx, 1);
syncMediaField();
renderThumbs();
syncPreview();
});
wrap.appendChild(btn);
thumbs.appendChild(wrap);
});
}
function syncPreview() {
var body = bodyEl ? bodyEl.value : '';
if (countEl) countEl.textContent = String(body.length);
if (!empty || !previewBody || !content || !previewMedia) return;
previewMedia.innerHTML = '';
mediaItems.forEach(function (item) {
if (item.type === 'video') {
var vid = document.createElement('video');
vid.src = item.url;
vid.controls = true;
vid.playsInline = true;
previewMedia.appendChild(vid);
} else {
var img = document.createElement('img');
img.src = item.url;
img.alt = item.filename || 'Image';
previewMedia.appendChild(img);
}
});
content.textContent = body;
var hasContent = !!(body || mediaItems.length);
empty.hidden = hasContent;
previewBody.hidden = !hasContent;
}
function syncWhen() {
if (!mode || !when) return;
when.disabled = mode.value !== 'schedule';
}
function validateBeforeUpload(file) {
var hasVideo = mediaItems.some(function (m) { return m.type === 'video'; });
var hasImage = mediaItems.some(function (m) { return m.type === 'image'; });
var isVideo = (file.type || '').indexOf('video/') === 0;
var isImage = (file.type || '').indexOf('image/') === 0;
if (!isVideo && !isImage) {
return 'Use an image or video file.';
}
if (isVideo && hasVideo) return 'Only one video per post.';
if (isVideo && hasImage) return 'Remove images before attaching a video.';
if (isImage && hasVideo) return 'Remove the video before attaching images.';
if (isImage && mediaItems.filter(function (m) { return m.type === 'image'; }).length >= 10) {
return 'At most 10 images.';
}
return '';
}
async function uploadFile(file) {
var err = validateBeforeUpload(file);
if (err) {
setStatus(err, true);
return;
}
setStatus('Uploading ' + (file.name || 'file') + '…');
var fd = new FormData();
fd.append('media', file);
try {
var res = await fetch(uploadUrl, {
method: 'POST',
headers: { 'X-CSRFToken': csrf },
body: fd,
credentials: 'same-origin'
});
var data = await res.json().catch(function () { return {}; });
if (!res.ok) {
throw new Error(data.error || ('Upload failed (' + res.status + ')'));
}
mediaItems.push(data);
syncMediaField();
renderThumbs();
syncPreview();
setStatus('Attached ' + (data.filename || data.type) + '.');
} catch (e) {
setStatus(e.message || 'Upload failed', true);
}
}
if (mediaInput) {
mediaInput.addEventListener('change', async function () {
var files = Array.prototype.slice.call(mediaInput.files || []);
mediaInput.value = '';
for (var i = 0; i < files.length; i++) {
await uploadFile(files[i]);
}
});
}
if (bodyEl) {
bodyEl.addEventListener('input', syncPreview);
bodyEl.addEventListener('keyup', syncPreview);
bodyEl.addEventListener('change', syncPreview);
}
if (mode) mode.addEventListener('change', syncWhen);
var generateForm = document.querySelector('form input[name=action][value=generate]')?.form;
if (generateForm) {
generateForm.addEventListener('submit', function () {
var genMedia = generateForm.querySelector('input[name=media_json]');
if (genMedia && mediaJson) genMedia.value = mediaJson.value;
var genBody = generateForm.querySelector('textarea[name=body]');
// keep account/mode already mirrored server-side on last render
});
}
syncMediaField();
renderThumbs();
syncPreview();
syncWhen();
})();
@@ -0,0 +1,63 @@
{% extends "portal_base.html" %}
{% block title %}Connecting Meta…{% endblock %}
{% block topbar_title %}Connecting…{% endblock %}
{% block portal_content %}
<div class="panel">
<div class="panel-h"><h2>Finishing Facebook Login</h2></div>
<div class="panel-b">
<p id="meta-oauth-status">Capturing access token from Facebook…</p>
<p class="muted" id="meta-oauth-error" style="display:none;color:#b91c1c"></p>
</div>
</div>
<form id="meta-oauth-complete" method="post" action="{% url 'social:meta_oauth_complete' %}" style="display:none">
{% csrf_token %}
<input type="hidden" name="access_token" id="meta-access-token" value="">
<input type="hidden" name="long_lived_token" id="meta-long-lived-token" value="">
<input type="hidden" name="expires_in" id="meta-expires-in" value="">
<input type="hidden" name="state" id="meta-state" value="">
<input type="hidden" name="error" id="meta-error" value="">
<input type="hidden" name="error_description" id="meta-error-description" value="">
</form>
{% endblock %}
{% block extra_js %}
<script>
(function () {
const status = document.getElementById('meta-oauth-status');
const errEl = document.getElementById('meta-oauth-error');
const form = document.getElementById('meta-oauth-complete');
const hash = (window.location.hash || '').replace(/^#/, '');
const params = new URLSearchParams(hash);
// Some Meta flows also put error on the query string.
const query = new URLSearchParams(window.location.search || '');
function fail(msg) {
status.textContent = 'Could not complete Facebook Login.';
errEl.style.display = 'block';
errEl.textContent = msg;
}
const error = params.get('error_reason') || params.get('error') || query.get('error') || '';
if (error) {
document.getElementById('meta-error').value = error;
document.getElementById('meta-error-description').value =
params.get('error_description') || query.get('error_description') || error;
form.submit();
return;
}
const accessToken = params.get('access_token') || '';
const longLived = params.get('long_lived_token') || '';
if (!accessToken && !longLived) {
fail('No access token in the redirect. Confirm the Valid OAuth Redirect URI matches exactly, then try Connect again.');
return;
}
document.getElementById('meta-access-token').value = accessToken;
document.getElementById('meta-long-lived-token').value = longLived;
document.getElementById('meta-expires-in').value = params.get('expires_in') || '';
document.getElementById('meta-state').value = params.get('state') || query.get('state') || '';
status.textContent = 'Token received — saving account…';
form.submit();
})();
</script>
{% endblock %}
@@ -10,6 +10,17 @@
<div class="panel">
<div class="panel-h"><h2>Caption</h2></div>
<div class="panel-b">
{% if post.media %}
<div style="display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px">
{% for item in post.media %}
{% if item.type == "video" %}
<video src="{{ item.url }}" controls playsinline style="max-width:100%;max-height:280px;border-radius:8px"></video>
{% else %}
<img src="{{ item.url }}" alt="{{ item.filename|default:'Image' }}" style="max-width:100%;max-height:280px;border-radius:8px;object-fit:contain">
{% endif %}
{% endfor %}
</div>
{% endif %}
<div class="preview-pane" style="white-space:pre-wrap">{{ post.body }}</div>
{% if post.scheduled_for %}
<p class="hint-block">Scheduled {{ post.scheduled_for|date:"M j, Y g:i A" }}</p>