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 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,
@@ -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.0.0",
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": ["agent", "workflow", "dashboard", "pwa"],
20
+ "keywords": [
21
+ "agent",
22
+ "workflow",
23
+ "dashboard",
24
+ "pwa"
25
+ ],
21
26
  "author": "DataTamer",
22
27
  "license": "MIT",
23
28
  "repository": {
@@ -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: 1000;
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: add ticket to board immediately
1022
- lastTodosList.push({
1023
- number: data.number,
1024
- repo: data.repo || repo,
1025
- title: title,
1026
- status: 'Todo',
1027
- hasClaude: true,
1028
- project_id: currentProjectId
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 sessionId = auth.createSession(user.id, user.email);
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}; Max-Age=${config.AUTH.SESSION_TTL / 1000}`
398
+ 'Set-Cookie': `session=${sessionId}; Path=/; HttpOnly; SameSite=Strict${secureCookie}${maxAgeCookie}`
396
399
  });
397
400
  res.end(JSON.stringify({ success: true }));
398
401
  } else {