agentdev-webui 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/auth.js +3 -3
- package/lib/config.js +8 -0
- package/lib/database.js +29 -0
- package/lib/linkedin.js +161 -0
- package/lib/routes.js +296 -0
- package/migrations/009_add_linkedin_tokens.sql +7 -0
- package/package.json +7 -2
- package/public/css/styles.css +4 -1
- package/public/js/app.js +28 -8
- package/public/login.html +7 -1
- package/server.js +6 -3
package/lib/auth.js
CHANGED
|
@@ -13,13 +13,13 @@ function hashPassword(password) {
|
|
|
13
13
|
return crypto.createHash('sha256').update(password).digest('hex');
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function createSession(userId, email) {
|
|
16
|
+
function createSession(userId, email, ttl) {
|
|
17
17
|
const sessionId = generateSessionId();
|
|
18
18
|
const session = {
|
|
19
19
|
userId,
|
|
20
20
|
email,
|
|
21
21
|
createdAt: Date.now(),
|
|
22
|
-
expiresAt: Date.now() + config.AUTH.SESSION_TTL
|
|
22
|
+
expiresAt: Date.now() + (ttl || config.AUTH.SESSION_TTL)
|
|
23
23
|
};
|
|
24
24
|
sessions.set(sessionId, session);
|
|
25
25
|
return sessionId;
|
|
@@ -87,7 +87,7 @@ function isAuthenticated(req) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
// Public paths that don't require authentication
|
|
90
|
-
const PUBLIC_PATHS = ['/login', '/login.html', '/register', '/register.html', '/reset-password', '/reset-password.html', '/verify-email', '/verify-email.html', '/docs', '/docs.html', '/docs.md', '/css/styles.css', '/manifest.json', '/sw.js', '/icon-192.png', '/icon-512.png', '/favicon.svg', '/favicon.ico'];
|
|
90
|
+
const PUBLIC_PATHS = ['/login', '/login.html', '/register', '/register.html', '/reset-password', '/reset-password.html', '/verify-email', '/verify-email.html', '/docs', '/docs.html', '/docs.md', '/css/styles.css', '/manifest.json', '/sw.js', '/icon-192.png', '/icon-512.png', '/favicon.svg', '/favicon.ico', '/og-image.svg'];
|
|
91
91
|
|
|
92
92
|
function requireAuth(req, res) {
|
|
93
93
|
const url = req.url.split('?')[0];
|
package/lib/config.js
CHANGED
|
@@ -48,6 +48,14 @@ module.exports = {
|
|
|
48
48
|
DEVICE_CODE_TTL: 600, // 10 minutes
|
|
49
49
|
DEVICE_CODE_POLL_INTERVAL: 5, // 5 seconds
|
|
50
50
|
|
|
51
|
+
// LinkedIn OAuth configuration
|
|
52
|
+
LINKEDIN: {
|
|
53
|
+
SCOPES: ['openid', 'profile', 'email', 'w_member_social'],
|
|
54
|
+
AUTH_URL: 'https://www.linkedin.com/oauth/v2/authorization',
|
|
55
|
+
TOKEN_URL: 'https://www.linkedin.com/oauth/v2/accessToken',
|
|
56
|
+
API_VERSION: '202601'
|
|
57
|
+
},
|
|
58
|
+
|
|
51
59
|
// Base URL for device authorization
|
|
52
60
|
BASE_URL: process.env.BASE_URL || 'http://localhost:3847'
|
|
53
61
|
};
|
package/lib/database.js
CHANGED
|
@@ -118,6 +118,33 @@ async function updateUserGitHubToken(userId, encryptedToken) {
|
|
|
118
118
|
return result.rows[0];
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
async function updateUserLinkedInTokens(userId, { accessToken, refreshToken, expiresAt }) {
|
|
122
|
+
const result = await query(
|
|
123
|
+
`UPDATE agentdev_users
|
|
124
|
+
SET linkedin_token_encrypted = $2,
|
|
125
|
+
linkedin_refresh_token_encrypted = $3,
|
|
126
|
+
linkedin_token_expires_at = $4,
|
|
127
|
+
updated_at = NOW()
|
|
128
|
+
WHERE id = $1
|
|
129
|
+
RETURNING id, email`,
|
|
130
|
+
[userId, accessToken, refreshToken, expiresAt]
|
|
131
|
+
);
|
|
132
|
+
return result.rows[0];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function updateUserLinkedInConfig(userId, { clientId, clientSecretEncrypted }) {
|
|
136
|
+
const result = await query(
|
|
137
|
+
`UPDATE agentdev_users
|
|
138
|
+
SET linkedin_client_id = $2,
|
|
139
|
+
linkedin_client_secret_encrypted = $3,
|
|
140
|
+
updated_at = NOW()
|
|
141
|
+
WHERE id = $1
|
|
142
|
+
RETURNING id, email`,
|
|
143
|
+
[userId, clientId, clientSecretEncrypted]
|
|
144
|
+
);
|
|
145
|
+
return result.rows[0];
|
|
146
|
+
}
|
|
147
|
+
|
|
121
148
|
// ============================================================================
|
|
122
149
|
// Project operations
|
|
123
150
|
// ============================================================================
|
|
@@ -713,6 +740,8 @@ module.exports = {
|
|
|
713
740
|
getUserById,
|
|
714
741
|
updateUserLimits,
|
|
715
742
|
updateUserGitHubToken,
|
|
743
|
+
updateUserLinkedInTokens,
|
|
744
|
+
updateUserLinkedInConfig,
|
|
716
745
|
|
|
717
746
|
// Agents
|
|
718
747
|
createAgent,
|
package/lib/linkedin.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const config = require('./config');
|
|
2
|
+
|
|
3
|
+
const LINKEDIN_API_VERSION = '202601';
|
|
4
|
+
const profileCache = new Map();
|
|
5
|
+
|
|
6
|
+
// --- REST helpers ---
|
|
7
|
+
|
|
8
|
+
async function linkedinRest(method, path, token, body = null) {
|
|
9
|
+
const baseUrl = path.startsWith('/v2/') ? 'https://api.linkedin.com' : 'https://api.linkedin.com';
|
|
10
|
+
const opts = {
|
|
11
|
+
method,
|
|
12
|
+
headers: {
|
|
13
|
+
'Authorization': `Bearer ${token}`,
|
|
14
|
+
'X-Restli-Protocol-Version': '2.0.0',
|
|
15
|
+
'LinkedIn-Version': LINKEDIN_API_VERSION
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
if (body) {
|
|
19
|
+
opts.headers['Content-Type'] = 'application/json';
|
|
20
|
+
opts.body = JSON.stringify(body);
|
|
21
|
+
}
|
|
22
|
+
const res = await fetch(`${baseUrl}${path}`, opts);
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const text = await res.text();
|
|
25
|
+
throw new Error(`LinkedIn API ${method} ${path} failed (${res.status}): ${text}`);
|
|
26
|
+
}
|
|
27
|
+
if (res.status === 204) return null;
|
|
28
|
+
return res.json();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- OAuth helpers ---
|
|
32
|
+
|
|
33
|
+
function getAuthorizationUrl(clientId, redirectUri, state, scopes = ['openid', 'profile', 'email', 'w_member_social']) {
|
|
34
|
+
const params = new URLSearchParams({
|
|
35
|
+
response_type: 'code',
|
|
36
|
+
client_id: clientId,
|
|
37
|
+
redirect_uri: redirectUri,
|
|
38
|
+
state: state,
|
|
39
|
+
scope: scopes.join(' ')
|
|
40
|
+
});
|
|
41
|
+
return `https://www.linkedin.com/oauth/v2/authorization?${params.toString()}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function exchangeCodeForToken(code, clientId, clientSecret, redirectUri) {
|
|
45
|
+
const params = new URLSearchParams({
|
|
46
|
+
grant_type: 'authorization_code',
|
|
47
|
+
code: code,
|
|
48
|
+
client_id: clientId,
|
|
49
|
+
client_secret: clientSecret,
|
|
50
|
+
redirect_uri: redirectUri
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const res = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
56
|
+
body: params.toString()
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
const text = await res.text();
|
|
61
|
+
throw new Error(`LinkedIn token exchange failed (${res.status}): ${text}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return res.json();
|
|
65
|
+
// Returns: { access_token, expires_in, refresh_token?, refresh_token_expires_in?, scope }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function refreshAccessToken(refreshToken, clientId, clientSecret) {
|
|
69
|
+
const params = new URLSearchParams({
|
|
70
|
+
grant_type: 'refresh_token',
|
|
71
|
+
refresh_token: refreshToken,
|
|
72
|
+
client_id: clientId,
|
|
73
|
+
client_secret: clientSecret
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const res = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
79
|
+
body: params.toString()
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
const text = await res.text();
|
|
84
|
+
throw new Error(`LinkedIn token refresh failed (${res.status}): ${text}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return res.json();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Public API functions ---
|
|
91
|
+
|
|
92
|
+
async function fetchProfile(token) {
|
|
93
|
+
const cacheKey = `profile:${token.slice(-8)}`;
|
|
94
|
+
const cached = profileCache.get(cacheKey);
|
|
95
|
+
if (cached && Date.now() - cached.timestamp < config.CACHE_TTL) {
|
|
96
|
+
return cached.data;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const data = await linkedinRest('GET', '/v2/userinfo', token);
|
|
101
|
+
profileCache.set(cacheKey, { data, timestamp: Date.now() });
|
|
102
|
+
return data;
|
|
103
|
+
// Returns: { sub, name, given_name, family_name, picture, locale, email, email_verified }
|
|
104
|
+
} catch (e) {
|
|
105
|
+
console.error('LinkedIn fetchProfile error:', e.message);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function createPost(token, authorUrn, commentary, visibility = 'PUBLIC') {
|
|
111
|
+
const body = {
|
|
112
|
+
author: authorUrn,
|
|
113
|
+
commentary: commentary,
|
|
114
|
+
visibility: visibility,
|
|
115
|
+
distribution: {
|
|
116
|
+
feedDistribution: 'MAIN_FEED',
|
|
117
|
+
targetEntities: [],
|
|
118
|
+
thirdPartyDistributionChannels: []
|
|
119
|
+
},
|
|
120
|
+
lifecycleState: 'PUBLISHED',
|
|
121
|
+
isReshareDisabledByAuthor: false
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return await linkedinRest('POST', '/rest/posts', token, body);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function getMyPosts(token, authorUrn) {
|
|
128
|
+
try {
|
|
129
|
+
const encodedUrn = encodeURIComponent(authorUrn);
|
|
130
|
+
return await linkedinRest('GET', `/rest/posts?q=author&author=${encodedUrn}&count=10`, token);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.error('LinkedIn getMyPosts error:', e.message);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function deletePost(token, postUrn) {
|
|
138
|
+
try {
|
|
139
|
+
const encodedUrn = encodeURIComponent(postUrn);
|
|
140
|
+
await linkedinRest('DELETE', `/rest/posts/${encodedUrn}`, token);
|
|
141
|
+
return true;
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.error('LinkedIn deletePost error:', e.message);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function clearCache() {
|
|
149
|
+
profileCache.clear();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
getAuthorizationUrl,
|
|
154
|
+
exchangeCodeForToken,
|
|
155
|
+
refreshAccessToken,
|
|
156
|
+
fetchProfile,
|
|
157
|
+
createPost,
|
|
158
|
+
getMyPosts,
|
|
159
|
+
deletePost,
|
|
160
|
+
clearCache
|
|
161
|
+
};
|
package/lib/routes.js
CHANGED
|
@@ -3,6 +3,8 @@ const deviceFlow = require('./device-flow');
|
|
|
3
3
|
const db = require('./database');
|
|
4
4
|
const encryption = require('./encryption');
|
|
5
5
|
const auth = require('./auth');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const linkedin = require('./linkedin');
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Register all API routes
|
|
@@ -671,6 +673,300 @@ function registerRoutes(req, res) {
|
|
|
671
673
|
return true;
|
|
672
674
|
}
|
|
673
675
|
|
|
676
|
+
// ============================================================================
|
|
677
|
+
// LinkedIn API Routes (require user session)
|
|
678
|
+
// ============================================================================
|
|
679
|
+
|
|
680
|
+
// POST /api/linkedin/config — Save LinkedIn OAuth app credentials
|
|
681
|
+
if (url === '/api/linkedin/config' && method === 'POST') {
|
|
682
|
+
if (!auth.isAuthenticated(req)) {
|
|
683
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
684
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
let body = '';
|
|
689
|
+
req.on('data', chunk => body += chunk);
|
|
690
|
+
req.on('end', async () => {
|
|
691
|
+
try {
|
|
692
|
+
const { client_id, client_secret } = JSON.parse(body);
|
|
693
|
+
if (!client_id || !client_secret) {
|
|
694
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
695
|
+
res.end(JSON.stringify({ error: 'client_id and client_secret are required' }));
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const userId = 1;
|
|
700
|
+
const encryptedSecret = encryption.encrypt(client_secret);
|
|
701
|
+
await db.updateUserLinkedInConfig(userId, {
|
|
702
|
+
clientId: client_id,
|
|
703
|
+
clientSecretEncrypted: encryptedSecret
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
707
|
+
res.end(JSON.stringify({ success: true }));
|
|
708
|
+
} catch (error) {
|
|
709
|
+
console.error('LinkedIn config error:', error);
|
|
710
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
711
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// GET /api/linkedin/config — Get LinkedIn OAuth config status
|
|
718
|
+
if (url === '/api/linkedin/config' && method === 'GET') {
|
|
719
|
+
if (!auth.isAuthenticated(req)) {
|
|
720
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
721
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
(async () => {
|
|
726
|
+
try {
|
|
727
|
+
const userId = 1;
|
|
728
|
+
const user = await db.getUserById(userId);
|
|
729
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
730
|
+
res.end(JSON.stringify({
|
|
731
|
+
configured: !!user?.linkedin_client_id,
|
|
732
|
+
client_id: user?.linkedin_client_id || null,
|
|
733
|
+
connected: !!user?.linkedin_token_encrypted,
|
|
734
|
+
token_expires_at: user?.linkedin_token_expires_at || null
|
|
735
|
+
}));
|
|
736
|
+
} catch (error) {
|
|
737
|
+
console.error('LinkedIn config get error:', error);
|
|
738
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
739
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
740
|
+
}
|
|
741
|
+
})();
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// GET /api/linkedin/authorize — Start OAuth flow (redirect to LinkedIn)
|
|
746
|
+
if (url === '/api/linkedin/authorize' && method === 'GET') {
|
|
747
|
+
if (!auth.isAuthenticated(req)) {
|
|
748
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
749
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
(async () => {
|
|
754
|
+
try {
|
|
755
|
+
const userId = 1;
|
|
756
|
+
const user = await db.getUserById(userId);
|
|
757
|
+
if (!user?.linkedin_client_id || !user?.linkedin_client_secret_encrypted) {
|
|
758
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
759
|
+
res.end(JSON.stringify({ error: 'LinkedIn OAuth not configured. Save client_id and client_secret first.' }));
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const config = require('./config');
|
|
764
|
+
const redirectUri = `${config.BASE_URL}/api/linkedin/callback`;
|
|
765
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
766
|
+
|
|
767
|
+
const authUrl = linkedin.getAuthorizationUrl(
|
|
768
|
+
user.linkedin_client_id,
|
|
769
|
+
redirectUri,
|
|
770
|
+
state
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
774
|
+
res.end(JSON.stringify({ authorization_url: authUrl, state }));
|
|
775
|
+
} catch (error) {
|
|
776
|
+
console.error('LinkedIn authorize error:', error);
|
|
777
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
778
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
779
|
+
}
|
|
780
|
+
})();
|
|
781
|
+
return true;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// GET /api/linkedin/callback — OAuth callback handler
|
|
785
|
+
if (url.startsWith('/api/linkedin/callback') && method === 'GET') {
|
|
786
|
+
(async () => {
|
|
787
|
+
try {
|
|
788
|
+
const urlObj = new URL(req.url, `http://${req.headers.host}`);
|
|
789
|
+
const code = urlObj.searchParams.get('code');
|
|
790
|
+
const error = urlObj.searchParams.get('error');
|
|
791
|
+
|
|
792
|
+
if (error) {
|
|
793
|
+
res.writeHead(302, { 'Location': '/?linkedin_error=' + encodeURIComponent(error) });
|
|
794
|
+
res.end();
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (!code) {
|
|
799
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
800
|
+
res.end(JSON.stringify({ error: 'Missing authorization code' }));
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const userId = 1;
|
|
805
|
+
const user = await db.getUserById(userId);
|
|
806
|
+
if (!user?.linkedin_client_id || !user?.linkedin_client_secret_encrypted) {
|
|
807
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
808
|
+
res.end(JSON.stringify({ error: 'LinkedIn OAuth not configured' }));
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const clientSecret = encryption.decrypt(user.linkedin_client_secret_encrypted);
|
|
813
|
+
const appConfig = require('./config');
|
|
814
|
+
const redirectUri = `${appConfig.BASE_URL}/api/linkedin/callback`;
|
|
815
|
+
|
|
816
|
+
const tokenData = await linkedin.exchangeCodeForToken(
|
|
817
|
+
code,
|
|
818
|
+
user.linkedin_client_id,
|
|
819
|
+
clientSecret,
|
|
820
|
+
redirectUri
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
const expiresAt = new Date(Date.now() + tokenData.expires_in * 1000);
|
|
824
|
+
await db.updateUserLinkedInTokens(userId, {
|
|
825
|
+
accessToken: encryption.encrypt(tokenData.access_token),
|
|
826
|
+
refreshToken: tokenData.refresh_token ? encryption.encrypt(tokenData.refresh_token) : null,
|
|
827
|
+
expiresAt
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
res.writeHead(302, { 'Location': '/?linkedin_connected=true' });
|
|
831
|
+
res.end();
|
|
832
|
+
} catch (error) {
|
|
833
|
+
console.error('LinkedIn callback error:', error);
|
|
834
|
+
res.writeHead(302, { 'Location': '/?linkedin_error=' + encodeURIComponent(error.message) });
|
|
835
|
+
res.end();
|
|
836
|
+
}
|
|
837
|
+
})();
|
|
838
|
+
return true;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// GET /api/linkedin/profile — Get authenticated user's LinkedIn profile
|
|
842
|
+
if (url === '/api/linkedin/profile' && method === 'GET') {
|
|
843
|
+
if (!auth.isAuthenticated(req)) {
|
|
844
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
845
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
(async () => {
|
|
850
|
+
try {
|
|
851
|
+
const userId = 1;
|
|
852
|
+
const user = await db.getUserById(userId);
|
|
853
|
+
if (!user?.linkedin_token_encrypted) {
|
|
854
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
855
|
+
res.end(JSON.stringify({ error: 'LinkedIn not connected. Complete OAuth flow first.' }));
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const token = encryption.decrypt(user.linkedin_token_encrypted);
|
|
860
|
+
const profile = await linkedin.fetchProfile(token);
|
|
861
|
+
|
|
862
|
+
if (!profile) {
|
|
863
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
864
|
+
res.end(JSON.stringify({ error: 'Failed to fetch LinkedIn profile' }));
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
869
|
+
res.end(JSON.stringify(profile));
|
|
870
|
+
} catch (error) {
|
|
871
|
+
console.error('LinkedIn profile error:', error);
|
|
872
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
873
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
874
|
+
}
|
|
875
|
+
})();
|
|
876
|
+
return true;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// POST /api/linkedin/posts — Create a LinkedIn post
|
|
880
|
+
if (url === '/api/linkedin/posts' && method === 'POST') {
|
|
881
|
+
if (!auth.isAuthenticated(req)) {
|
|
882
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
883
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
884
|
+
return true;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
let body = '';
|
|
888
|
+
req.on('data', chunk => body += chunk);
|
|
889
|
+
req.on('end', async () => {
|
|
890
|
+
try {
|
|
891
|
+
const { commentary, visibility } = JSON.parse(body);
|
|
892
|
+
if (!commentary) {
|
|
893
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
894
|
+
res.end(JSON.stringify({ error: 'commentary is required' }));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const userId = 1;
|
|
899
|
+
const user = await db.getUserById(userId);
|
|
900
|
+
if (!user?.linkedin_token_encrypted) {
|
|
901
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
902
|
+
res.end(JSON.stringify({ error: 'LinkedIn not connected' }));
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const token = encryption.decrypt(user.linkedin_token_encrypted);
|
|
907
|
+
|
|
908
|
+
// Get user profile to construct author URN
|
|
909
|
+
const profile = await linkedin.fetchProfile(token);
|
|
910
|
+
if (!profile?.sub) {
|
|
911
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
912
|
+
res.end(JSON.stringify({ error: 'Could not determine LinkedIn user ID' }));
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const authorUrn = `urn:li:person:${profile.sub}`;
|
|
917
|
+
const result = await linkedin.createPost(token, authorUrn, commentary, visibility || 'PUBLIC');
|
|
918
|
+
|
|
919
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
920
|
+
res.end(JSON.stringify({ success: true, post: result }));
|
|
921
|
+
} catch (error) {
|
|
922
|
+
console.error('LinkedIn post create error:', error);
|
|
923
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
924
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// GET /api/linkedin/posts — Get user's LinkedIn posts
|
|
931
|
+
if (url === '/api/linkedin/posts' && method === 'GET') {
|
|
932
|
+
if (!auth.isAuthenticated(req)) {
|
|
933
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
934
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
935
|
+
return true;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
(async () => {
|
|
939
|
+
try {
|
|
940
|
+
const userId = 1;
|
|
941
|
+
const user = await db.getUserById(userId);
|
|
942
|
+
if (!user?.linkedin_token_encrypted) {
|
|
943
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
944
|
+
res.end(JSON.stringify({ error: 'LinkedIn not connected' }));
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const token = encryption.decrypt(user.linkedin_token_encrypted);
|
|
949
|
+
const profile = await linkedin.fetchProfile(token);
|
|
950
|
+
if (!profile?.sub) {
|
|
951
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
952
|
+
res.end(JSON.stringify({ error: 'Could not determine LinkedIn user ID' }));
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const authorUrn = `urn:li:person:${profile.sub}`;
|
|
957
|
+
const posts = await linkedin.getMyPosts(token, authorUrn);
|
|
958
|
+
|
|
959
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
960
|
+
res.end(JSON.stringify(posts || { elements: [] }));
|
|
961
|
+
} catch (error) {
|
|
962
|
+
console.error('LinkedIn posts list error:', error);
|
|
963
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
964
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
965
|
+
}
|
|
966
|
+
})();
|
|
967
|
+
return true;
|
|
968
|
+
}
|
|
969
|
+
|
|
674
970
|
// Route not handled
|
|
675
971
|
return false;
|
|
676
972
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- Add LinkedIn OAuth token columns to agentdev_users
|
|
2
|
+
ALTER TABLE agentdev_users
|
|
3
|
+
ADD COLUMN IF NOT EXISTS linkedin_token_encrypted TEXT,
|
|
4
|
+
ADD COLUMN IF NOT EXISTS linkedin_refresh_token_encrypted TEXT,
|
|
5
|
+
ADD COLUMN IF NOT EXISTS linkedin_token_expires_at TIMESTAMPTZ,
|
|
6
|
+
ADD COLUMN IF NOT EXISTS linkedin_client_id TEXT,
|
|
7
|
+
ADD COLUMN IF NOT EXISTS linkedin_client_secret_encrypted TEXT;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentdev-webui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Multi-agent workflow dashboard for auto-ticket processing",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,12 @@
|
|
|
17
17
|
"dev": "nodemon --watch server.js --watch lib --watch public --ext js,html,css,json --delay 500ms server.js",
|
|
18
18
|
"test": "echo \"No tests yet\" && exit 0"
|
|
19
19
|
},
|
|
20
|
-
"keywords": [
|
|
20
|
+
"keywords": [
|
|
21
|
+
"agent",
|
|
22
|
+
"workflow",
|
|
23
|
+
"dashboard",
|
|
24
|
+
"pwa"
|
|
25
|
+
],
|
|
21
26
|
"author": "DataTamer",
|
|
22
27
|
"license": "MIT",
|
|
23
28
|
"repository": {
|
package/public/css/styles.css
CHANGED
|
@@ -308,7 +308,7 @@ body.offline { padding-top: 32px; }
|
|
|
308
308
|
border: 1px solid #333;
|
|
309
309
|
border-radius: 8px;
|
|
310
310
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
311
|
-
z-index:
|
|
311
|
+
z-index: 1050;
|
|
312
312
|
overflow: hidden;
|
|
313
313
|
}
|
|
314
314
|
.notification-panel.open { display: block; }
|
|
@@ -1088,10 +1088,13 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
1088
1088
|
.logs-panel { padding: 12px; }
|
|
1089
1089
|
.log-entry { font-size: 11px; padding: 6px 10px; }
|
|
1090
1090
|
.scroll-to-bottom { right: 24px; }
|
|
1091
|
+
/* Hide status text label on mobile - dot alone is sufficient */
|
|
1092
|
+
#statusTxt { display: none; }
|
|
1091
1093
|
/* Hide less-important action buttons on mobile to keep logout visible */
|
|
1092
1094
|
.actions-group .logout-btn:not(#logoutBtn) { display: none; }
|
|
1093
1095
|
.project-selector { max-width: 120px; font-size: 11px; }
|
|
1094
1096
|
.manage-projects-btn { min-width: 36px; min-height: 36px; padding: 2px 6px; }
|
|
1097
|
+
#statusTxt { display: none; }
|
|
1095
1098
|
}
|
|
1096
1099
|
|
|
1097
1100
|
/* Scroll to bottom button */
|
package/public/js/app.js
CHANGED
|
@@ -32,6 +32,7 @@ let projectsList = [];
|
|
|
32
32
|
let lastAgentsList = [];
|
|
33
33
|
let lastHistoryList = [];
|
|
34
34
|
let lastTodosList = [];
|
|
35
|
+
const optimisticTickets = new Map(); // "repo:number" → { ticket, created: Date.now() }
|
|
35
36
|
|
|
36
37
|
let firstProjectMode = false;
|
|
37
38
|
|
|
@@ -1018,14 +1019,18 @@ async function createTicket() {
|
|
|
1018
1019
|
formStatus.innerHTML = 'Ticket <a href="' + data.url + '" target="_blank">#' + data.number + '</a> created and added to project!';
|
|
1019
1020
|
formStatus.className = 'form-status visible success';
|
|
1020
1021
|
|
|
1021
|
-
// Optimistic update:
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1022
|
+
// Optimistic update: track separately so SSE broadcasts don't wipe it
|
|
1023
|
+
const ticketRepo = data.repo || repo;
|
|
1024
|
+
optimisticTickets.set(ticketRepo + ':' + data.number, {
|
|
1025
|
+
ticket: {
|
|
1026
|
+
number: data.number,
|
|
1027
|
+
repo: ticketRepo,
|
|
1028
|
+
title: title,
|
|
1029
|
+
status: 'Todo',
|
|
1030
|
+
hasClaude: true,
|
|
1031
|
+
project_id: currentProjectId
|
|
1032
|
+
},
|
|
1033
|
+
created: Date.now()
|
|
1029
1034
|
});
|
|
1030
1035
|
updateTodoTickets(lastTodosList);
|
|
1031
1036
|
|
|
@@ -1499,6 +1504,21 @@ const STATUS_COLORS = { 'Todo': '#4ade80', 'In Progress': '#fbbf24', 'test': '#6
|
|
|
1499
1504
|
const STATUS_ORDER = ['Todo', 'In Progress', 'test', 'Done'];
|
|
1500
1505
|
|
|
1501
1506
|
function updateTodoTickets(list) {
|
|
1507
|
+
// Merge optimistic tickets: remove confirmed, expire old, add remaining
|
|
1508
|
+
for (const [key] of optimisticTickets) {
|
|
1509
|
+
const [repo, num] = key.split(':');
|
|
1510
|
+
if (list.some(t => t.repo === repo && t.number === parseInt(num))) {
|
|
1511
|
+
optimisticTickets.delete(key);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
for (const [key, entry] of optimisticTickets) {
|
|
1515
|
+
if (Date.now() - entry.created > 60000) {
|
|
1516
|
+
optimisticTickets.delete(key);
|
|
1517
|
+
} else {
|
|
1518
|
+
list.push(entry.ticket);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1502
1522
|
// Store raw data for re-filtering
|
|
1503
1523
|
lastTodosList = list;
|
|
1504
1524
|
|
package/public/login.html
CHANGED
|
@@ -161,6 +161,11 @@
|
|
|
161
161
|
<input type="password" id="password" name="password" placeholder="Enter your password" required>
|
|
162
162
|
</div>
|
|
163
163
|
|
|
164
|
+
<div style="display: flex; align-items: center; margin-bottom: 16px;">
|
|
165
|
+
<input type="checkbox" id="remember" name="remember" checked style="width: auto; margin: 0 8px 0 0; accent-color: #4ade80; cursor: pointer;">
|
|
166
|
+
<label for="remember" style="color: rgba(255, 255, 255, 0.7); font-size: 14px; margin: 0; text-transform: none; letter-spacing: 0; cursor: pointer;">Remember me</label>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
164
169
|
<button type="submit" class="login-btn" id="loginBtn">Sign In</button>
|
|
165
170
|
</form>
|
|
166
171
|
|
|
@@ -189,6 +194,7 @@
|
|
|
189
194
|
|
|
190
195
|
const username = document.getElementById('username').value;
|
|
191
196
|
const password = document.getElementById('password').value;
|
|
197
|
+
const remember = document.getElementById('remember').checked;
|
|
192
198
|
const loginBtn = document.getElementById('loginBtn');
|
|
193
199
|
const loginError = document.getElementById('loginError');
|
|
194
200
|
|
|
@@ -200,7 +206,7 @@
|
|
|
200
206
|
const res = await fetch('/api/login', {
|
|
201
207
|
method: 'POST',
|
|
202
208
|
headers: { 'Content-Type': 'application/json' },
|
|
203
|
-
body: JSON.stringify({ username, password })
|
|
209
|
+
body: JSON.stringify({ username, password, remember })
|
|
204
210
|
});
|
|
205
211
|
|
|
206
212
|
const data = await res.json();
|
package/server.js
CHANGED
|
@@ -377,7 +377,7 @@ http.createServer(async (req, res) => {
|
|
|
377
377
|
req.on('data', chunk => body += chunk);
|
|
378
378
|
req.on('end', async () => {
|
|
379
379
|
try {
|
|
380
|
-
const { username, password } = JSON.parse(body);
|
|
380
|
+
const { username, password, remember } = JSON.parse(body);
|
|
381
381
|
const user = await auth.validateCredentials(username, password);
|
|
382
382
|
if (user) {
|
|
383
383
|
// Check if email is verified
|
|
@@ -386,13 +386,16 @@ http.createServer(async (req, res) => {
|
|
|
386
386
|
res.end(JSON.stringify({ success: false, error: 'Please verify your email before logging in' }));
|
|
387
387
|
return;
|
|
388
388
|
}
|
|
389
|
-
const
|
|
389
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
390
|
+
const sessionTtl = remember ? THIRTY_DAYS_MS : config.AUTH.SESSION_TTL;
|
|
391
|
+
const sessionId = auth.createSession(user.id, user.email, sessionTtl);
|
|
390
392
|
// Add Secure flag for HTTPS (production)
|
|
391
393
|
const isSecure = process.env.NODE_ENV === 'production' || process.env.BASE_URL?.startsWith('https://');
|
|
392
394
|
const secureCookie = isSecure ? '; Secure' : '';
|
|
395
|
+
const maxAgeCookie = remember ? `; Max-Age=${THIRTY_DAYS_MS / 1000}` : '';
|
|
393
396
|
res.writeHead(200, {
|
|
394
397
|
'Content-Type': 'application/json',
|
|
395
|
-
'Set-Cookie': `session=${sessionId}; Path=/; HttpOnly; SameSite=Strict${secureCookie}
|
|
398
|
+
'Set-Cookie': `session=${sessionId}; Path=/; HttpOnly; SameSite=Strict${secureCookie}${maxAgeCookie}`
|
|
396
399
|
});
|
|
397
400
|
res.end(JSON.stringify({ success: true }));
|
|
398
401
|
} else {
|