agentdev-webui 1.0.0 → 1.1.2

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.2",
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
@@ -1018,17 +1018,6 @@ async function createTicket() {
1018
1018
  formStatus.innerHTML = 'Ticket <a href="' + data.url + '" target="_blank">#' + data.number + '</a> created and added to project!';
1019
1019
  formStatus.className = 'form-status visible success';
1020
1020
 
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
1029
- });
1030
- updateTodoTickets(lastTodosList);
1031
-
1032
1021
  ticketDescription.value = '';
1033
1022
  setTimeout(closeCreateTicketModal, 2000);
1034
1023
  } else {
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
@@ -273,6 +273,10 @@ async function resolveProjectToken(project) {
273
273
  return process.env.GH_TOKEN || process.env.GITHUB_DEFAULT_TOKEN || null;
274
274
  }
275
275
 
276
+ // Track recently created tickets server-side so SSE broadcasts include them
277
+ // even before GitHub indexes them (avoids optimistic update flicker)
278
+ const recentlyCreatedTickets = new Map(); // "repo:number" → { ticket, created }
279
+
276
280
  async function broadcastTodoTickets() {
277
281
  try {
278
282
  let allTickets = [];
@@ -305,6 +309,19 @@ async function broadcastTodoTickets() {
305
309
  allTickets = await github.fetchProjectTickets();
306
310
  }
307
311
 
312
+ // Merge recently created tickets that GitHub hasn't indexed yet
313
+ for (const [key, entry] of recentlyCreatedTickets) {
314
+ const [repo, num] = key.split(':');
315
+ const numInt = parseInt(num);
316
+ if (allTickets.some(t => t.repo === repo && t.number === numInt)) {
317
+ recentlyCreatedTickets.delete(key); // Confirmed by GitHub
318
+ } else if (Date.now() - entry.created > 60000) {
319
+ recentlyCreatedTickets.delete(key); // Expired after 60s
320
+ } else {
321
+ allTickets.push(entry.ticket); // Add to broadcast
322
+ }
323
+ }
324
+
308
325
  broadcast({ type: 'todos', list: allTickets });
309
326
  } catch (error) {
310
327
  console.error('Error broadcasting project tickets:', error.message);
@@ -377,7 +394,7 @@ http.createServer(async (req, res) => {
377
394
  req.on('data', chunk => body += chunk);
378
395
  req.on('end', async () => {
379
396
  try {
380
- const { username, password } = JSON.parse(body);
397
+ const { username, password, remember } = JSON.parse(body);
381
398
  const user = await auth.validateCredentials(username, password);
382
399
  if (user) {
383
400
  // Check if email is verified
@@ -386,13 +403,16 @@ http.createServer(async (req, res) => {
386
403
  res.end(JSON.stringify({ success: false, error: 'Please verify your email before logging in' }));
387
404
  return;
388
405
  }
389
- const sessionId = auth.createSession(user.id, user.email);
406
+ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
407
+ const sessionTtl = remember ? THIRTY_DAYS_MS : config.AUTH.SESSION_TTL;
408
+ const sessionId = auth.createSession(user.id, user.email, sessionTtl);
390
409
  // Add Secure flag for HTTPS (production)
391
410
  const isSecure = process.env.NODE_ENV === 'production' || process.env.BASE_URL?.startsWith('https://');
392
411
  const secureCookie = isSecure ? '; Secure' : '';
412
+ const maxAgeCookie = remember ? `; Max-Age=${THIRTY_DAYS_MS / 1000}` : '';
393
413
  res.writeHead(200, {
394
414
  'Content-Type': 'application/json',
395
- 'Set-Cookie': `session=${sessionId}; Path=/; HttpOnly; SameSite=Strict${secureCookie}; Max-Age=${config.AUTH.SESSION_TTL / 1000}`
415
+ 'Set-Cookie': `session=${sessionId}; Path=/; HttpOnly; SameSite=Strict${secureCookie}${maxAgeCookie}`
396
416
  });
397
417
  res.end(JSON.stringify({ success: true }));
398
418
  } else {
@@ -1158,6 +1178,25 @@ http.createServer(async (req, res) => {
1158
1178
  console.log('Ticket #' + issue.number + ' added to database');
1159
1179
  }
1160
1180
 
1181
+ // Track the newly created ticket so SSE broadcasts include it
1182
+ // even before GitHub indexes it
1183
+ recentlyCreatedTickets.set(repo + ':' + issue.number, {
1184
+ ticket: {
1185
+ number: issue.number,
1186
+ repo: repo,
1187
+ title: title,
1188
+ body: ticketBody,
1189
+ state: 'OPEN',
1190
+ status: 'Todo',
1191
+ hasClaude: ticketBody.includes('@claude'),
1192
+ project_id: projectId,
1193
+ author: user?.username || 'unknown',
1194
+ createdAt: new Date().toISOString(),
1195
+ comments: []
1196
+ },
1197
+ created: Date.now()
1198
+ });
1199
+
1161
1200
  // Clear cache and broadcast updated board immediately
1162
1201
  github.clearTicketCache();
1163
1202
  broadcastTodoTickets();