Add LinkedIn OAuth connect with portal-saved app credentials.
Deploy Beta / unit-tests (push) Successful in 12s
Deploy Beta / docker (push) Successful in 17s
Deploy Beta / deploy-beta (push) Successful in 1m41s

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:
2026-08-11 07:52:10 -05:00
parent 2643ba7a96
commit 44d300271a
12 changed files with 517 additions and 26 deletions
+76 -1
View File
@@ -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 %}