agentgate 0.1.0

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.
Files changed (40) hide show
  1. package/README.md +216 -0
  2. package/package.json +63 -0
  3. package/public/favicon.svg +48 -0
  4. package/public/icons/bluesky.svg +1 -0
  5. package/public/icons/fitbit.svg +16 -0
  6. package/public/icons/github.svg +1 -0
  7. package/public/icons/google-calendar.svg +1 -0
  8. package/public/icons/jira.svg +1 -0
  9. package/public/icons/linkedin.svg +1 -0
  10. package/public/icons/mastodon.svg +1 -0
  11. package/public/icons/reddit.svg +1 -0
  12. package/public/icons/youtube.svg +1 -0
  13. package/public/logo.svg +52 -0
  14. package/public/style.css +584 -0
  15. package/src/cli.js +77 -0
  16. package/src/index.js +344 -0
  17. package/src/lib/db.js +325 -0
  18. package/src/lib/hsyncManager.js +57 -0
  19. package/src/lib/queueExecutor.js +362 -0
  20. package/src/routes/bluesky.js +130 -0
  21. package/src/routes/calendar.js +120 -0
  22. package/src/routes/fitbit.js +127 -0
  23. package/src/routes/github.js +72 -0
  24. package/src/routes/jira.js +77 -0
  25. package/src/routes/linkedin.js +137 -0
  26. package/src/routes/mastodon.js +91 -0
  27. package/src/routes/queue.js +186 -0
  28. package/src/routes/reddit.js +138 -0
  29. package/src/routes/ui/bluesky.js +66 -0
  30. package/src/routes/ui/calendar.js +120 -0
  31. package/src/routes/ui/fitbit.js +122 -0
  32. package/src/routes/ui/github.js +60 -0
  33. package/src/routes/ui/index.js +35 -0
  34. package/src/routes/ui/jira.js +72 -0
  35. package/src/routes/ui/linkedin.js +120 -0
  36. package/src/routes/ui/mastodon.js +140 -0
  37. package/src/routes/ui/reddit.js +120 -0
  38. package/src/routes/ui/youtube.js +120 -0
  39. package/src/routes/ui.js +1077 -0
  40. package/src/routes/youtube.js +119 -0
@@ -0,0 +1,138 @@
1
+ import { Router } from 'express';
2
+ import { getAccountCredentials, setAccountCredentials } from '../lib/db.js';
3
+
4
+ const router = Router();
5
+ const REDDIT_API = 'https://oauth.reddit.com';
6
+ const REDDIT_AUTH = 'https://www.reddit.com/api/v1';
7
+
8
+ // Service metadata - exported for /api/readme and /api/skill
9
+ export const serviceInfo = {
10
+ key: 'reddit',
11
+ name: 'Reddit',
12
+ shortDesc: 'Subreddits, posts, comments (DMs blocked)',
13
+ description: 'Reddit API proxy (DMs blocked)',
14
+ authType: 'oauth',
15
+ docs: 'https://www.reddit.com/dev/api/',
16
+ examples: [
17
+ 'GET /api/reddit/{accountName}/api/v1/me',
18
+ 'GET /api/reddit/{accountName}/r/{subreddit}/hot',
19
+ 'GET /api/reddit/{accountName}/user/{username}/submitted'
20
+ ]
21
+ };
22
+
23
+ // Blocked routes - no DMs/private messages
24
+ const BLOCKED_PATTERNS = [
25
+ /^message\//, // /message/inbox, /message/sent, etc.
26
+ /^api\/v1\/me\/blocked/, // blocked users list
27
+ /^api\/v1\/me\/friends/ // friends list (privacy)
28
+ ];
29
+
30
+ // Get a valid access token, refreshing if needed
31
+ async function getAccessToken(accountName) {
32
+ const creds = getAccountCredentials('reddit', accountName);
33
+ if (!creds) {
34
+ return null;
35
+ }
36
+
37
+ // If we have an access token and it's not expired, use it
38
+ if (creds.accessToken && creds.expiresAt && Date.now() < creds.expiresAt) {
39
+ return creds.accessToken;
40
+ }
41
+
42
+ // Need to refresh the token
43
+ if (!creds.refreshToken) {
44
+ return null;
45
+ }
46
+
47
+ try {
48
+ const basicAuth = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString('base64');
49
+
50
+ const response = await fetch(`${REDDIT_AUTH}/access_token`, {
51
+ method: 'POST',
52
+ headers: {
53
+ 'Authorization': `Basic ${basicAuth}`,
54
+ 'Content-Type': 'application/x-www-form-urlencoded'
55
+ },
56
+ body: new URLSearchParams({
57
+ grant_type: 'refresh_token',
58
+ refresh_token: creds.refreshToken
59
+ })
60
+ });
61
+
62
+ if (!response.ok) {
63
+ console.error('Reddit token refresh failed:', await response.text());
64
+ return null;
65
+ }
66
+
67
+ const tokens = await response.json();
68
+
69
+ // Store the new tokens
70
+ setAccountCredentials('reddit', accountName, {
71
+ ...creds,
72
+ accessToken: tokens.access_token,
73
+ expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000 // 1 min buffer
74
+ });
75
+
76
+ return tokens.access_token;
77
+ } catch (error) {
78
+ console.error('Reddit token refresh failed:', error);
79
+ return null;
80
+ }
81
+ }
82
+
83
+ // Proxy GET requests to Reddit API
84
+ // Route: /api/reddit/:accountName/*
85
+ router.get('/:accountName/*', async (req, res) => {
86
+ try {
87
+ const { accountName } = req.params;
88
+ const accessToken = await getAccessToken(accountName);
89
+ if (!accessToken) {
90
+ return res.status(401).json({
91
+ error: 'Reddit account not configured',
92
+ message: `Set up Reddit account "${accountName}" in the admin UI`
93
+ });
94
+ }
95
+
96
+ const path = req.params[0] || '';
97
+
98
+ // Check blocked routes
99
+ for (const pattern of BLOCKED_PATTERNS) {
100
+ if (pattern.test(path)) {
101
+ return res.status(403).json({
102
+ error: 'Route blocked',
103
+ message: 'This endpoint is blocked for privacy (DMs/messages)'
104
+ });
105
+ }
106
+ }
107
+
108
+ const queryString = new URLSearchParams(req.query).toString();
109
+ const url = `${REDDIT_API}/${path}${queryString ? '?' + queryString : ''}`;
110
+
111
+ const response = await fetch(url, {
112
+ headers: {
113
+ 'Authorization': `Bearer ${accessToken}`,
114
+ 'User-Agent': 'agentgate-gateway/1.0'
115
+ }
116
+ });
117
+
118
+ const data = await response.json();
119
+ res.status(response.status).json(data);
120
+ } catch (error) {
121
+ res.status(500).json({ error: 'Reddit API request failed', message: error.message });
122
+ }
123
+ });
124
+
125
+ // Handle root path for account
126
+ router.get('/:accountName', async (req, res) => {
127
+ res.json({
128
+ service: 'reddit',
129
+ account: req.params.accountName,
130
+ description: 'Reddit API proxy (DMs blocked). Append API path after account name.',
131
+ examples: [
132
+ `GET /api/reddit/${req.params.accountName}/api/v1/me`,
133
+ `GET /api/reddit/${req.params.accountName}/r/programming/hot`
134
+ ]
135
+ });
136
+ });
137
+
138
+ export default router;
@@ -0,0 +1,66 @@
1
+ import { setAccountCredentials, deleteAccount, getAccountCredentials } from '../../lib/db.js';
2
+
3
+ export function registerRoutes(router) {
4
+ router.post('/bluesky/setup', (req, res) => {
5
+ const { accountName, identifier, appPassword } = req.body;
6
+ if (!accountName || !identifier || !appPassword) {
7
+ return res.status(400).send('Account name, identifier, and app password required');
8
+ }
9
+ setAccountCredentials('bluesky', accountName, { identifier, appPassword });
10
+ res.redirect('/ui');
11
+ });
12
+
13
+ router.post('/bluesky/delete', (req, res) => {
14
+ const { accountName } = req.body;
15
+ deleteAccount('bluesky', accountName);
16
+ res.redirect('/ui');
17
+ });
18
+ }
19
+
20
+ export function renderCard(accounts, _baseUrl) {
21
+ const serviceAccounts = accounts.filter(a => a.service === 'bluesky');
22
+
23
+ const renderAccounts = () => {
24
+ if (serviceAccounts.length === 0) return '';
25
+ return serviceAccounts.map(acc => {
26
+ const creds = getAccountCredentials('bluesky', acc.name);
27
+ const info = creds?.identifier ? `${acc.name} (${creds.identifier})` : acc.name;
28
+ return `
29
+ <div class="account-item">
30
+ <span><strong>${info}</strong></span>
31
+ <form method="POST" action="/ui/bluesky/delete" style="margin:0;">
32
+ <input type="hidden" name="accountName" value="${acc.name}">
33
+ <button type="submit" class="btn-sm btn-danger">Remove</button>
34
+ </form>
35
+ </div>
36
+ `;
37
+ }).join('');
38
+ };
39
+
40
+ return `
41
+ <div class="card">
42
+ <div class="service-header">
43
+ <img class="service-icon" src="/public/icons/bluesky.svg" alt="Bluesky">
44
+ <h3>Bluesky</h3>
45
+ </div>
46
+ ${renderAccounts()}
47
+ <details>
48
+ <summary>Add Bluesky Account</summary>
49
+ <div style="margin-top: 15px;">
50
+ <p class="help">Create an app password at <a href="https://bsky.app/settings/app-passwords" target="_blank">bsky.app/settings/app-passwords</a></p>
51
+ <form method="POST" action="/ui/bluesky/setup">
52
+ <label>Account Name</label>
53
+ <input type="text" name="accountName" placeholder="main, alt, etc." required>
54
+ <label>Handle (no @ symbol)</label>
55
+ <input type="text" name="identifier" placeholder="yourname.bsky.social" required>
56
+ <label>App Password</label>
57
+ <input type="password" name="appPassword" placeholder="xxxx-xxxx-xxxx-xxxx" required>
58
+ <button type="submit" class="btn-primary">Add Account</button>
59
+ </form>
60
+ </div>
61
+ </details>
62
+ </div>`;
63
+ }
64
+
65
+ export const serviceName = 'bluesky';
66
+ export const displayName = 'Bluesky';
@@ -0,0 +1,120 @@
1
+ import { setAccountCredentials, deleteAccount, getAccountCredentials } from '../../lib/db.js';
2
+
3
+ export function registerRoutes(router, baseUrl) {
4
+ router.post('/google/setup', (req, res) => {
5
+ const { accountName, clientId, clientSecret } = req.body;
6
+ if (!accountName || !clientId || !clientSecret) {
7
+ return res.status(400).send('Account name, client ID, and secret required');
8
+ }
9
+ setAccountCredentials('google_calendar', accountName, { clientId, clientSecret });
10
+
11
+ const redirectUri = `${baseUrl}/ui/google/callback`;
12
+ const scope = 'https://www.googleapis.com/auth/calendar.readonly';
13
+ const state = `agentgate_google_${accountName}`;
14
+
15
+ const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth?' +
16
+ `client_id=${clientId}&response_type=code&` +
17
+ `state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}&` +
18
+ `scope=${encodeURIComponent(scope)}&access_type=offline&prompt=consent`;
19
+
20
+ res.redirect(authUrl);
21
+ });
22
+
23
+ router.get('/google/callback', async (req, res) => {
24
+ const { code, error, state } = req.query;
25
+ if (error) {
26
+ return res.status(400).send(`Google OAuth error: ${error}`);
27
+ }
28
+
29
+ const accountName = state?.replace('agentgate_google_', '') || 'default';
30
+ const creds = getAccountCredentials('google_calendar', accountName);
31
+ if (!creds) {
32
+ return res.status(400).send('Google Calendar account not found. Please try setup again.');
33
+ }
34
+
35
+ try {
36
+ const redirectUri = `${baseUrl}/ui/google/callback`;
37
+
38
+ const response = await fetch('https://oauth2.googleapis.com/token', {
39
+ method: 'POST',
40
+ headers: {
41
+ 'Content-Type': 'application/x-www-form-urlencoded'
42
+ },
43
+ body: new URLSearchParams({
44
+ client_id: creds.clientId,
45
+ client_secret: creds.clientSecret,
46
+ code,
47
+ grant_type: 'authorization_code',
48
+ redirect_uri: redirectUri
49
+ })
50
+ });
51
+
52
+ const tokens = await response.json();
53
+ if (tokens.error) {
54
+ return res.status(400).send(`Google token error: ${tokens.error}`);
55
+ }
56
+
57
+ setAccountCredentials('google_calendar', accountName, {
58
+ ...creds,
59
+ accessToken: tokens.access_token,
60
+ refreshToken: tokens.refresh_token,
61
+ expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
62
+ });
63
+
64
+ res.redirect('/ui');
65
+ } catch (err) {
66
+ res.status(500).send(`Google OAuth failed: ${err.message}`);
67
+ }
68
+ });
69
+
70
+ router.post('/google/delete', (req, res) => {
71
+ const { accountName } = req.body;
72
+ deleteAccount('google_calendar', accountName);
73
+ res.redirect('/ui');
74
+ });
75
+ }
76
+
77
+ export function renderCard(accounts, baseUrl) {
78
+ const serviceAccounts = accounts.filter(a => a.service === 'google_calendar');
79
+
80
+ const renderAccounts = () => {
81
+ if (serviceAccounts.length === 0) return '';
82
+ return serviceAccounts.map(acc => `
83
+ <div class="account-item">
84
+ <span><strong>${acc.name}</strong></span>
85
+ <form method="POST" action="/ui/google/delete" style="margin:0;">
86
+ <input type="hidden" name="accountName" value="${acc.name}">
87
+ <button type="submit" class="btn-sm btn-danger">Remove</button>
88
+ </form>
89
+ </div>
90
+ `).join('');
91
+ };
92
+
93
+ return `
94
+ <div class="card">
95
+ <div class="service-header">
96
+ <img class="service-icon" src="/public/icons/google-calendar.svg" alt="Google Calendar">
97
+ <h3>Google Calendar</h3>
98
+ </div>
99
+ ${renderAccounts()}
100
+ <details>
101
+ <summary>Add Google Calendar Account</summary>
102
+ <div style="margin-top: 15px;">
103
+ <p class="help">Create OAuth credentials at <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>. Enable the Calendar API.</p>
104
+ <p class="help">Redirect URI: <span class="copyable">${baseUrl}/ui/google/callback <button type="button" class="copy-btn" onclick="copyText('${baseUrl}/ui/google/callback', this)">Copy</button></span></p>
105
+ <form method="POST" action="/ui/google/setup">
106
+ <label>Account Name</label>
107
+ <input type="text" name="accountName" placeholder="personal, work, etc." required>
108
+ <label>Client ID</label>
109
+ <input type="text" name="clientId" placeholder="xxxxxxxx.apps.googleusercontent.com" required>
110
+ <label>Client Secret</label>
111
+ <input type="password" name="clientSecret" placeholder="Google client secret" required>
112
+ <button type="submit" class="btn-primary">Add Account</button>
113
+ </form>
114
+ </div>
115
+ </details>
116
+ </div>`;
117
+ }
118
+
119
+ export const serviceName = 'google_calendar';
120
+ export const displayName = 'Google Calendar';
@@ -0,0 +1,122 @@
1
+ import { setAccountCredentials, deleteAccount, getAccountCredentials } from '../../lib/db.js';
2
+
3
+ export function registerRoutes(router, baseUrl) {
4
+ router.post('/fitbit/setup', (req, res) => {
5
+ const { accountName, clientId, clientSecret } = req.body;
6
+ if (!accountName || !clientId || !clientSecret) {
7
+ return res.status(400).send('Account name, client ID, and secret required');
8
+ }
9
+ setAccountCredentials('fitbit', accountName, { clientId, clientSecret });
10
+
11
+ const redirectUri = `${baseUrl}/ui/fitbit/callback`;
12
+ const scope = 'activity heartrate location nutrition oxygen_saturation profile respiratory_rate settings sleep social temperature weight';
13
+ const state = `agentgate_fitbit_${accountName}`;
14
+
15
+ const authUrl = 'https://www.fitbit.com/oauth2/authorize?' +
16
+ `client_id=${clientId}&response_type=code&` +
17
+ `state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}&` +
18
+ `scope=${encodeURIComponent(scope)}`;
19
+
20
+ res.redirect(authUrl);
21
+ });
22
+
23
+ router.get('/fitbit/callback', async (req, res) => {
24
+ const { code, error, state } = req.query;
25
+ if (error) {
26
+ return res.status(400).send(`Fitbit OAuth error: ${error}`);
27
+ }
28
+
29
+ const accountName = state?.replace('agentgate_fitbit_', '') || 'default';
30
+ const creds = getAccountCredentials('fitbit', accountName);
31
+ if (!creds) {
32
+ return res.status(400).send('Fitbit account not found. Please try setup again.');
33
+ }
34
+
35
+ try {
36
+ const redirectUri = `${baseUrl}/ui/fitbit/callback`;
37
+ const basicAuth = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString('base64');
38
+
39
+ const response = await fetch('https://api.fitbit.com/oauth2/token', {
40
+ method: 'POST',
41
+ headers: {
42
+ 'Content-Type': 'application/x-www-form-urlencoded',
43
+ 'Authorization': `Basic ${basicAuth}`
44
+ },
45
+ body: new URLSearchParams({
46
+ client_id: creds.clientId,
47
+ code,
48
+ grant_type: 'authorization_code',
49
+ redirect_uri: redirectUri
50
+ })
51
+ });
52
+
53
+ const tokens = await response.json();
54
+ if (tokens.errors) {
55
+ return res.status(400).send(`Fitbit token error: ${tokens.errors[0]?.message || 'Unknown error'}`);
56
+ }
57
+
58
+ setAccountCredentials('fitbit', accountName, {
59
+ ...creds,
60
+ accessToken: tokens.access_token,
61
+ refreshToken: tokens.refresh_token,
62
+ userId: tokens.user_id,
63
+ expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
64
+ });
65
+
66
+ res.redirect('/ui');
67
+ } catch (err) {
68
+ res.status(500).send(`Fitbit OAuth failed: ${err.message}`);
69
+ }
70
+ });
71
+
72
+ router.post('/fitbit/delete', (req, res) => {
73
+ const { accountName } = req.body;
74
+ deleteAccount('fitbit', accountName);
75
+ res.redirect('/ui');
76
+ });
77
+ }
78
+
79
+ export function renderCard(accounts, baseUrl) {
80
+ const serviceAccounts = accounts.filter(a => a.service === 'fitbit');
81
+
82
+ const renderAccounts = () => {
83
+ if (serviceAccounts.length === 0) return '';
84
+ return serviceAccounts.map(acc => `
85
+ <div class="account-item">
86
+ <span><strong>${acc.name}</strong></span>
87
+ <form method="POST" action="/ui/fitbit/delete" style="margin:0;">
88
+ <input type="hidden" name="accountName" value="${acc.name}">
89
+ <button type="submit" class="btn-sm btn-danger">Remove</button>
90
+ </form>
91
+ </div>
92
+ `).join('');
93
+ };
94
+
95
+ return `
96
+ <div class="card">
97
+ <div class="service-header">
98
+ <img class="service-icon" src="/public/icons/fitbit.svg" alt="Fitbit">
99
+ <h3>Fitbit</h3>
100
+ </div>
101
+ ${renderAccounts()}
102
+ <details>
103
+ <summary>Add Fitbit Account</summary>
104
+ <div style="margin-top: 15px;">
105
+ <p class="help">Create an app at <a href="https://dev.fitbit.com/apps/new" target="_blank">Fitbit Developer</a>. Set OAuth 2.0 Application Type to "Personal".</p>
106
+ <p class="help">Redirect URI: <span class="copyable">${baseUrl}/ui/fitbit/callback <button type="button" class="copy-btn" onclick="copyText('${baseUrl}/ui/fitbit/callback', this)">Copy</button></span></p>
107
+ <form method="POST" action="/ui/fitbit/setup">
108
+ <label>Account Name</label>
109
+ <input type="text" name="accountName" placeholder="personal, etc." required>
110
+ <label>Client ID (OAuth 2.0 Client ID)</label>
111
+ <input type="text" name="clientId" placeholder="Fitbit client ID" required>
112
+ <label>Client Secret</label>
113
+ <input type="password" name="clientSecret" placeholder="Fitbit client secret" required>
114
+ <button type="submit" class="btn-primary">Add Account</button>
115
+ </form>
116
+ </div>
117
+ </details>
118
+ </div>`;
119
+ }
120
+
121
+ export const serviceName = 'fitbit';
122
+ export const displayName = 'Fitbit';
@@ -0,0 +1,60 @@
1
+ import { setAccountCredentials, deleteAccount } from '../../lib/db.js';
2
+
3
+ export function registerRoutes(router) {
4
+ router.post('/github/setup', (req, res) => {
5
+ const { accountName, token } = req.body;
6
+ if (!accountName || !token) {
7
+ return res.status(400).send('Account name and personal access token required');
8
+ }
9
+ setAccountCredentials('github', accountName, { token });
10
+ res.redirect('/ui');
11
+ });
12
+
13
+ router.post('/github/delete', (req, res) => {
14
+ const { accountName } = req.body;
15
+ deleteAccount('github', accountName);
16
+ res.redirect('/ui');
17
+ });
18
+ }
19
+
20
+ export function renderCard(accounts, _baseUrl) {
21
+ const serviceAccounts = accounts.filter(a => a.service === 'github');
22
+
23
+ const renderAccounts = () => {
24
+ if (serviceAccounts.length === 0) return '';
25
+ return serviceAccounts.map(acc => `
26
+ <div class="account-item">
27
+ <span><strong>${acc.name}</strong></span>
28
+ <form method="POST" action="/ui/github/delete" style="margin:0;">
29
+ <input type="hidden" name="accountName" value="${acc.name}">
30
+ <button type="submit" class="btn-sm btn-danger">Remove</button>
31
+ </form>
32
+ </div>
33
+ `).join('');
34
+ };
35
+
36
+ return `
37
+ <div class="card">
38
+ <div class="service-header">
39
+ <img class="service-icon" src="/public/icons/github.svg" alt="GitHub">
40
+ <h3>GitHub</h3>
41
+ </div>
42
+ ${renderAccounts()}
43
+ <details>
44
+ <summary>Add GitHub Account</summary>
45
+ <div style="margin-top: 15px;">
46
+ <p class="help">Create a token at <a href="https://github.com/settings/tokens" target="_blank">github.com/settings/tokens</a></p>
47
+ <form method="POST" action="/ui/github/setup">
48
+ <label>Account Name</label>
49
+ <input type="text" name="accountName" placeholder="personal, work, etc." required>
50
+ <label>Personal Access Token</label>
51
+ <input type="password" name="token" placeholder="ghp_xxxx or github_pat_xxxx" required>
52
+ <button type="submit" class="btn-primary">Add Account</button>
53
+ </form>
54
+ </div>
55
+ </details>
56
+ </div>`;
57
+ }
58
+
59
+ export const serviceName = 'github';
60
+ export const displayName = 'GitHub';
@@ -0,0 +1,35 @@
1
+ // Service handlers - add new services here
2
+ import * as github from './github.js';
3
+ import * as bluesky from './bluesky.js';
4
+ import * as reddit from './reddit.js';
5
+ import * as calendar from './calendar.js';
6
+ import * as youtube from './youtube.js';
7
+ import * as mastodon from './mastodon.js';
8
+ import * as linkedin from './linkedin.js';
9
+ import * as jira from './jira.js';
10
+ import * as fitbit from './fitbit.js';
11
+
12
+ // Export all services in display order
13
+ export const services = [
14
+ github,
15
+ bluesky,
16
+ mastodon,
17
+ reddit,
18
+ calendar,
19
+ youtube,
20
+ fitbit,
21
+ jira,
22
+ linkedin
23
+ ];
24
+
25
+ // Register all service routes
26
+ export function registerAllRoutes(router, baseUrl) {
27
+ for (const service of services) {
28
+ service.registerRoutes(router, baseUrl);
29
+ }
30
+ }
31
+
32
+ // Render all service cards
33
+ export function renderAllCards(accounts, baseUrl) {
34
+ return services.map(service => service.renderCard(accounts, baseUrl)).join('\n');
35
+ }
@@ -0,0 +1,72 @@
1
+ import { setAccountCredentials, deleteAccount, getAccountCredentials } from '../../lib/db.js';
2
+
3
+ export function registerRoutes(router, _baseUrl) {
4
+ router.post('/jira/setup', (req, res) => {
5
+ const { accountName, domain, email, apiToken } = req.body;
6
+ if (!accountName || !domain || !email || !apiToken) {
7
+ return res.status(400).send('Account name, domain, email, and API token required');
8
+ }
9
+ setAccountCredentials('jira', accountName, {
10
+ domain: domain.replace(/^https?:\/\//, '').replace(/\/$/, ''),
11
+ email,
12
+ apiToken
13
+ });
14
+ res.redirect('/ui');
15
+ });
16
+
17
+ router.post('/jira/delete', (req, res) => {
18
+ const { accountName } = req.body;
19
+ deleteAccount('jira', accountName);
20
+ res.redirect('/ui');
21
+ });
22
+ }
23
+
24
+ export function renderCard(accounts, _baseUrl) {
25
+ const serviceAccounts = accounts.filter(a => a.service === 'jira');
26
+
27
+ const renderAccounts = () => {
28
+ if (serviceAccounts.length === 0) return '';
29
+ return serviceAccounts.map(acc => {
30
+ const creds = getAccountCredentials('jira', acc.name);
31
+ const info = creds?.domain ? `${acc.name} (${creds.domain})` : acc.name;
32
+ return `
33
+ <div class="account-item">
34
+ <span><strong>${info}</strong></span>
35
+ <form method="POST" action="/ui/jira/delete" style="margin:0;">
36
+ <input type="hidden" name="accountName" value="${acc.name}">
37
+ <button type="submit" class="btn-sm btn-danger">Remove</button>
38
+ </form>
39
+ </div>
40
+ `;
41
+ }).join('');
42
+ };
43
+
44
+ return `
45
+ <div class="card">
46
+ <div class="service-header">
47
+ <img class="service-icon" src="/public/icons/jira.svg" alt="Jira">
48
+ <h3>Jira</h3>
49
+ </div>
50
+ ${renderAccounts()}
51
+ <details>
52
+ <summary>Add Jira Account</summary>
53
+ <div style="margin-top: 15px;">
54
+ <p class="help">Create an API token at <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank">Atlassian Account Settings</a>.</p>
55
+ <form method="POST" action="/ui/jira/setup">
56
+ <label>Account Name</label>
57
+ <input type="text" name="accountName" placeholder="work, client, etc." required>
58
+ <label>Jira Domain</label>
59
+ <input type="text" name="domain" placeholder="yourcompany.atlassian.net" required>
60
+ <label>Email</label>
61
+ <input type="text" name="email" placeholder="you@company.com" required>
62
+ <label>API Token</label>
63
+ <input type="password" name="apiToken" placeholder="Your Jira API token" required>
64
+ <button type="submit" class="btn-primary">Add Account</button>
65
+ </form>
66
+ </div>
67
+ </details>
68
+ </div>`;
69
+ }
70
+
71
+ export const serviceName = 'jira';
72
+ export const displayName = 'Jira';