diff --git a/views/project/_tab_botconfig.php b/views/project/_tab_botconfig.php
index 4ff7482..ae6a846 100644
--- a/views/project/_tab_botconfig.php
+++ b/views/project/_tab_botconfig.php
@@ -40,10 +40,9 @@
Checking…
-
+
+
diff --git a/web/api/linkedin_auth.php b/web/api/linkedin_auth.php
new file mode 100644
index 0000000..1784fb2
--- /dev/null
+++ b/web/api/linkedin_auth.php
@@ -0,0 +1,27 @@
+ 'project_id required']); exit; }
+
+$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
+$stmt->execute([$pid]);
+if (!$stmt->fetch()) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
+
+$client_id = getenv('LINKEDIN_CLIENT_ID');
+if (!$client_id) { echo json_encode(['error' => 'LINKEDIN_CLIENT_ID not set in server environment']); exit; }
+
+$state = bin2hex(random_bytes(16));
+$_SESSION['linkedin_oauth'] = ['state' => $state, 'project_id' => $pid];
+
+$redirect_uri = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . '/linkedin/callback';
+
+$url = 'https://www.linkedin.com/oauth/v2/authorization?' . http_build_query([
+ 'response_type' => 'code',
+ 'client_id' => $client_id,
+ 'redirect_uri' => $redirect_uri,
+ 'state' => $state,
+ 'scope' => 'w_organization_social openid profile',
+]);
+
+echo json_encode(['url' => $url]);
diff --git a/web/assets/js/app.js b/web/assets/js/app.js
index 2ee4574..e5dcbfd 100644
--- a/web/assets/js/app.js
+++ b/web/assets/js/app.js
@@ -2364,6 +2364,7 @@ if (addScanPathForm) {
botconfig_mastodon_remove: 'mastodon removed',
botconfig_linkedin_page_add: 'linkedin page added', botconfig_linkedin_page_save: 'linkedin page updated',
botconfig_linkedin_page_remove: 'linkedin page removed',
+ linkedin_oauth_connect: 'linkedin connected',
file_write: 'file edit', file_delete: 'file delete', file_upload: 'upload',
post_create: 'post created', post_delete: 'post deleted',
post_publish: 'post published', post_duplicate: 'post duplicated',
@@ -2399,6 +2400,7 @@ if (addScanPathForm) {
botconfig_rss_add: 'bi-rss', botconfig_rss_save: 'bi-rss', botconfig_rss_remove: 'bi-rss',
botconfig_mastodon_add: 'bi-mastodon', botconfig_mastodon_save: 'bi-mastodon', botconfig_mastodon_remove: 'bi-mastodon',
botconfig_linkedin_page_add: 'bi-linkedin', botconfig_linkedin_page_save: 'bi-linkedin', botconfig_linkedin_page_remove: 'bi-linkedin',
+ linkedin_oauth_connect: 'bi-linkedin',
file_write: 'bi-pencil', file_delete: 'bi-trash', file_upload: 'bi-cloud-upload',
post_create: 'bi-file-earmark-plus', post_delete: 'bi-file-earmark-x',
post_publish: 'bi-send', post_duplicate: 'bi-files',
@@ -3386,6 +3388,13 @@ window.addEventListener('DOMContentLoaded', () => {
expiry.textContent = li.token_expiry ? 'Expires: ' + li.token_expiry : '';
}
+ document.getElementById('liConnectBtn').addEventListener('click', async () => {
+ const r = await fetch('/api/linkedin_auth?project_id=' + pid);
+ const d = await r.json();
+ if (d.error) { showError(d.error); return; }
+ window.location.href = d.url;
+ });
+
function renderLinkedinPages(pages) {
const list = document.getElementById('linkedinPagesList');
const names = Object.keys(pages);
diff --git a/web/index.php b/web/index.php
index ace7bd6..05235e0 100644
--- a/web/index.php
+++ b/web/index.php
@@ -15,6 +15,13 @@ if ($uri === '/api/auth') {
exit;
}
+// LinkedIn OAuth callback — session required but no full auth check
+// (LinkedIn redirects back here; user's browser session is active)
+if ($uri === '/linkedin/callback') {
+ include ROOT . '/web/linkedin_callback.php';
+ exit;
+}
+
// All other routes require login
Auth::requireLogin();
ProjectTypes::load();
diff --git a/web/linkedin_callback.php b/web/linkedin_callback.php
new file mode 100644
index 0000000..c7ac664
--- /dev/null
+++ b/web/linkedin_callback.php
@@ -0,0 +1,81 @@
+Go back';
+ exit;
+}
+
+$pid = (int)($_SESSION['linkedin_oauth']['project_id'] ?? 0);
+unset($_SESSION['linkedin_oauth']);
+
+if (isset($_GET['error'])) {
+ $msg = htmlspecialchars($_GET['error_description'] ?? $_GET['error']);
+ echo "LinkedIn denied access: $msg. Go back";
+ exit;
+}
+
+$code = $_GET['code'] ?? '';
+if (!$code) { echo 'Missing code. Go back'; exit; }
+
+$client_id = getenv('LINKEDIN_CLIENT_ID');
+$client_secret = getenv('LINKEDIN_CLIENT_SECRET');
+$redirect_uri = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . '/linkedin/callback';
+
+// Exchange code for tokens
+$ch = curl_init('https://www.linkedin.com/oauth/v2/accessToken');
+curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => http_build_query([
+ 'grant_type' => 'authorization_code',
+ 'code' => $code,
+ 'redirect_uri' => $redirect_uri,
+ 'client_id' => $client_id,
+ 'client_secret' => $client_secret,
+ ]),
+ CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
+]);
+$body = curl_exec($ch);
+$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+curl_close($ch);
+
+$data = json_decode($body, true);
+if ($status !== 200 || empty($data['access_token'])) {
+ $err = htmlspecialchars($data['error_description'] ?? $data['error'] ?? 'Unknown error');
+ echo "LinkedIn token exchange failed: $err. Go back";
+ exit;
+}
+
+// Get the project's config.json path
+$stmt = $db->prepare('SELECT path FROM projects WHERE id = ? AND is_active = 1');
+$stmt->execute([$pid]);
+$project = $stmt->fetch();
+if (!$project) { echo 'Project not found. Go back'; exit; }
+
+$config_path = realpath($project['path']) . '/data/config.json';
+
+if (!file_exists($config_path)) {
+ echo "config.json not found at $config_path. Go back";
+ exit;
+}
+
+$config = json_decode(file_get_contents($config_path), true);
+if (!is_array($config)) { echo 'Could not parse config.json. Go back'; exit; }
+
+if (!isset($config['linkedin'])) $config['linkedin'] = [];
+$config['linkedin']['access_token'] = $data['access_token'];
+$config['linkedin']['refresh_token'] = $data['refresh_token'] ?? null;
+$expires_in = $data['expires_in'] ?? 5184000;
+$config['linkedin']['token_expiry'] = date('c', time() + $expires_in);
+
+$tmp = $config_path . '.tmp';
+file_put_contents($tmp, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+rename($tmp, $config_path);
+
+Audit::log($db, 'linkedin_oauth_connect', $pid);
+
+header('Location: /project/' . $pid . '?tab=botconfig');
+exit;