From cf69c21210bb95f689113cab90c1bbcfea501f93 Mon Sep 17 00:00:00 2001 From: jonmartin721 Date: Wed, 11 Mar 2026 16:40:09 -0500 Subject: [PATCH 01/11] add github oauth device flow scaffolding --- manifest.json | 5 +- shared/auth.js | 216 ++++++++++++++++++++++++++++++++++ shared/config.js | 13 +- shared/storage-helpers.js | 79 +++++++++++++ tests/auth.test.js | 122 +++++++++++++++++++ tests/storage-helpers.test.js | 91 ++++++++++++++ 6 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 shared/auth.js create mode 100644 tests/auth.test.js diff --git a/manifest.json b/manifest.json index 3b9504f..1c7478f 100644 --- a/manifest.json +++ b/manifest.json @@ -10,7 +10,8 @@ "notifications" ], "host_permissions": [ - "https://api.github.com/*" + "https://api.github.com/*", + "https://github.com/*" ], "background": { "service_worker": "background.js", @@ -33,6 +34,6 @@ "128": "icons/icon128.png" }, "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://api.github.com https://registry.npmjs.org; img-src 'self' https: data:; default-src 'self'; style-src 'self'" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://api.github.com https://github.com https://registry.npmjs.org; img-src 'self' https: data:; default-src 'self'; style-src 'self'" } } diff --git a/shared/auth.js b/shared/auth.js new file mode 100644 index 0000000..6c1ff02 --- /dev/null +++ b/shared/auth.js @@ -0,0 +1,216 @@ +import { API_CONFIG, OAUTH_CONFIG } from './config.js'; + +function buildFormBody(params) { + const body = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + body.set(key, value); + } + }); + + return body.toString(); +} + +function getOAuthHeaders() { + return { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + }; +} + +async function parseOAuthResponse(response) { + const text = await response.text(); + + if (!text) { + return {}; + } + + try { + return JSON.parse(text); + } catch (_error) { + return Object.fromEntries(new URLSearchParams(text).entries()); + } +} + +function createOAuthError(message, code, details = {}) { + const error = new Error(message); + error.code = code; + Object.assign(error, details); + return error; +} + +function waitFor(ms, signal) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + + function onAbort() { + cleanup(); + reject(createOAuthError('GitHub sign-in was cancelled.', 'aborted')); + } + + function cleanup() { + clearTimeout(timeoutId); + signal?.removeEventListener('abort', onAbort); + } + + if (signal?.aborted) { + cleanup(); + reject(createOAuthError('GitHub sign-in was cancelled.', 'aborted')); + return; + } + + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +function normalizeScopes(scopeValue = '') { + return String(scopeValue) + .split(/[,\s]+/) + .map(scope => scope.trim()) + .filter(Boolean); +} + +function getScopeString(scopes = OAUTH_CONFIG.SCOPES) { + return scopes.join(' '); +} + +export function createOAuthHeaders(accessToken) { + return { + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/vnd.github.v3+json' + }; +} + +export function createGitHubAuthSession(tokenData, user) { + const now = new Date().toISOString(); + + return { + authType: 'oauth_device', + accessToken: tokenData.accessToken, + tokenType: tokenData.tokenType || 'bearer', + scopes: tokenData.scopes || [], + username: user?.login || '', + userId: user?.id || null, + grantedAt: now, + expiresIn: tokenData.expiresIn ?? null, + refreshToken: tokenData.refreshToken ?? null, + refreshTokenExpiresIn: tokenData.refreshTokenExpiresIn ?? null + }; +} + +export async function requestGitHubDeviceCode() { + const response = await fetch(OAUTH_CONFIG.DEVICE_CODE_URL, { + method: 'POST', + headers: getOAuthHeaders(), + body: buildFormBody({ + client_id: OAUTH_CONFIG.CLIENT_ID, + scope: getScopeString() + }) + }); + + const data = await parseOAuthResponse(response); + + if (!response.ok) { + throw createOAuthError( + data.error_description || 'GitHub sign-in could not be started.', + data.error || 'device_code_failed', + { status: response.status } + ); + } + + return { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri || OAUTH_CONFIG.DEVICE_VERIFY_URL, + verificationUriComplete: data.verification_uri_complete || null, + expiresIn: data.expires_in, + interval: data.interval ?? 5 + }; +} + +export function openGitHubDevicePage(deviceCodeData) { + const targetUrl = deviceCodeData.verificationUriComplete || deviceCodeData.verificationUri || OAUTH_CONFIG.DEVICE_VERIFY_URL; + + if (chrome?.tabs?.create) { + chrome.tabs.create({ url: targetUrl }); + } +} + +export async function pollForGitHubAccessToken(deviceCodeData, options = {}) { + const { signal, onPoll } = options; + const startedAt = Date.now(); + const expiresAt = startedAt + ((deviceCodeData.expiresIn ?? 900) * 1000); + let intervalMs = (deviceCodeData.interval ?? 5) * 1000; + + while (Date.now() < expiresAt) { + onPoll?.({ + expiresAt, + intervalMs, + remainingMs: Math.max(0, expiresAt - Date.now()) + }); + + const response = await fetch(OAUTH_CONFIG.ACCESS_TOKEN_URL, { + method: 'POST', + headers: getOAuthHeaders(), + body: buildFormBody({ + client_id: OAUTH_CONFIG.CLIENT_ID, + device_code: deviceCodeData.deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code' + }), + signal + }); + + const data = await parseOAuthResponse(response); + + if (data.access_token) { + return { + accessToken: data.access_token, + tokenType: data.token_type || 'bearer', + scopes: normalizeScopes(data.scope), + refreshToken: data.refresh_token || null, + refreshTokenExpiresIn: data.refresh_token_expires_in ?? null, + expiresIn: data.expires_in ?? null + }; + } + + switch (data.error) { + case 'authorization_pending': + await waitFor(intervalMs, signal); + continue; + case 'slow_down': + intervalMs += 5000; + await waitFor(intervalMs, signal); + continue; + case 'access_denied': + throw createOAuthError('GitHub sign-in was denied.', 'access_denied'); + case 'expired_token': + throw createOAuthError('GitHub sign-in code expired. Start again to reconnect.', 'expired_token'); + default: + throw createOAuthError( + data.error_description || 'GitHub sign-in failed while waiting for approval.', + data.error || 'poll_failed', + { status: response.status } + ); + } + } + + throw createOAuthError('GitHub sign-in code expired. Start again to reconnect.', 'expired_token'); +} + +export async function fetchGitHubUser(accessToken) { + const response = await fetch(`${API_CONFIG.GITHUB_API_BASE}/user`, { + headers: createOAuthHeaders(accessToken) + }); + + if (!response.ok) { + throw createOAuthError('Could not load the GitHub account after sign-in.', 'user_fetch_failed', { + status: response.status + }); + } + + return response.json(); +} diff --git a/shared/config.js b/shared/config.js index c338a0c..fefb200 100644 --- a/shared/config.js +++ b/shared/config.js @@ -16,6 +16,16 @@ export const API_CONFIG = { MAX_REPOS_PER_REQUEST: 100 }; +// OAuth Configuration +export const OAUTH_CONFIG = { + CLIENT_ID: 'YOUR_GITHUB_OAUTH_CLIENT_ID', + SCOPES: ['repo', 'read:user'], + DEVICE_CODE_URL: 'https://github.com/login/device/code', + ACCESS_TOKEN_URL: 'https://github.com/login/oauth/access_token', + DEVICE_VERIFY_URL: 'https://github.com/login/device', + AUTHORIZED_APPS_URL: 'https://github.com/settings/applications' +}; + // Rate Limiting Configuration export const RATE_LIMIT_CONFIG = { DEFAULT_CHECK_INTERVAL: 15, // minutes @@ -129,6 +139,7 @@ export const DEV_CONFIG = { // Export all configurations as a single object for easy access export const CONFIG = { API: API_CONFIG, + OAUTH: OAUTH_CONFIG, RATE_LIMIT: RATE_LIMIT_CONFIG, STORAGE: STORAGE_CONFIG, UI: UI_CONFIG, @@ -139,4 +150,4 @@ export const CONFIG = { VALIDATION_PATTERNS, DEFAULTS, DEV: DEV_CONFIG -}; \ No newline at end of file +}; diff --git a/shared/storage-helpers.js b/shared/storage-helpers.js index 35013a2..d065e2f 100644 --- a/shared/storage-helpers.js +++ b/shared/storage-helpers.js @@ -3,6 +3,9 @@ */ import { encryptData, decryptData } from './crypto-utils.js'; +const AUTH_SESSION_CACHE_KEY = 'githubAuthSession'; +const AUTH_SESSION_STORAGE_KEY = 'encryptedGithubAuthSession'; + /** * Check if running in Chrome extension context * @returns {boolean} True if Chrome APIs are available @@ -163,6 +166,82 @@ export function getExcludedRepos(mutedRepos = [], snoozedRepos = []) { ]); } +/** + * Get the stored GitHub auth session + * Tries session storage first (decrypted cache), then local storage (encrypted) + * @returns {Promise} Auth session or null + */ +export async function getAuthSession() { + if (isChromeExtension() && chrome.storage.session) { + const cachedSession = await new Promise(resolve => { + chrome.storage.session.get([AUTH_SESSION_CACHE_KEY], result => resolve(result[AUTH_SESSION_CACHE_KEY])); + }); + + if (cachedSession && typeof cachedSession === 'object') { + return cachedSession; + } + } + + const encrypted = await getLocalItem(AUTH_SESSION_STORAGE_KEY); + if (!encrypted) { + return null; + } + + try { + const decrypted = await decryptData(encrypted); + const session = typeof decrypted === 'string' ? JSON.parse(decrypted) : decrypted; + + if (!session || typeof session !== 'object') { + return null; + } + + if (isChromeExtension() && chrome.storage.session) { + await new Promise(resolve => { + chrome.storage.session.set({ [AUTH_SESSION_CACHE_KEY]: session }, resolve); + }); + } + + return session; + } catch (_error) { + return null; + } +} + +/** + * Persist a GitHub auth session + * @param {Object|null} session - Auth session to store + * @returns {Promise} + */ +export async function setAuthSession(session) { + if (!session || typeof session !== 'object' || !session.accessToken) { + await clearAuthSession(); + return; + } + + if (isChromeExtension() && chrome.storage.session) { + await new Promise(resolve => { + chrome.storage.session.set({ [AUTH_SESSION_CACHE_KEY]: session }, resolve); + }); + } + + const encrypted = await encryptData(JSON.stringify(session)); + await setLocalItem(AUTH_SESSION_STORAGE_KEY, encrypted); +} + +/** + * Clear the stored GitHub auth session + * @returns {Promise} + */ +export async function clearAuthSession() { + if (isChromeExtension() && chrome.storage.session) { + await new Promise(resolve => { + chrome.storage.session.remove([AUTH_SESSION_CACHE_KEY], resolve); + }); + } + + await setLocalItem(AUTH_SESSION_STORAGE_KEY, null); +} + /** * Get GitHub token * Tries session storage first (decrypted cache), then local storage (encrypted) diff --git a/tests/auth.test.js b/tests/auth.test.js new file mode 100644 index 0000000..e5790fc --- /dev/null +++ b/tests/auth.test.js @@ -0,0 +1,122 @@ +import { jest } from '@jest/globals'; + +const mockCreateTab = jest.fn(); + +global.chrome = { + tabs: { + create: mockCreateTab + } +}; + +describe('GitHub OAuth helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = jest.fn(); + }); + + it('requests a GitHub device code', async () => { + const { requestGitHubDeviceCode } = await import('../shared/auth.js'); + + global.fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ + device_code: 'device-code', + user_code: 'ABCD-EFGH', + verification_uri: 'https://github.com/login/device', + verification_uri_complete: 'https://github.com/login/device?user_code=ABCD-EFGH', + expires_in: 900, + interval: 5 + })) + }); + + const result = await requestGitHubDeviceCode(); + + expect(result).toEqual({ + deviceCode: 'device-code', + userCode: 'ABCD-EFGH', + verificationUri: 'https://github.com/login/device', + verificationUriComplete: 'https://github.com/login/device?user_code=ABCD-EFGH', + expiresIn: 900, + interval: 5 + }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://github.com/login/device/code', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Accept': 'application/json' + }) + }) + ); + }); + + it('opens the GitHub verification page in a new tab', async () => { + const { openGitHubDevicePage } = await import('../shared/auth.js'); + + openGitHubDevicePage({ + verificationUri: 'https://github.com/login/device', + verificationUriComplete: 'https://github.com/login/device?user_code=ABCD-EFGH' + }); + + expect(mockCreateTab).toHaveBeenCalledWith({ + url: 'https://github.com/login/device?user_code=ABCD-EFGH' + }); + }); + + it('polls until the user approves sign-in', async () => { + const { pollForGitHubAccessToken } = await import('../shared/auth.js'); + + global.fetch + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ + error: 'authorization_pending' + })) + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ + access_token: 'oauth-token', + token_type: 'bearer', + scope: 'repo read:user' + })) + }); + + const result = await pollForGitHubAccessToken({ + deviceCode: 'device-code', + expiresIn: 900, + interval: 0 + }); + + expect(result).toEqual({ + accessToken: 'oauth-token', + tokenType: 'bearer', + scopes: ['repo', 'read:user'], + refreshToken: null, + refreshTokenExpiresIn: null, + expiresIn: null + }); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('fetches the authenticated GitHub user with bearer auth', async () => { + const { fetchGitHubUser } = await import('../shared/auth.js'); + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ login: 'octocat', id: 1 }) + }); + + const result = await fetchGitHubUser('oauth-token'); + + expect(result).toEqual({ login: 'octocat', id: 1 }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.github.com/user', + expect.objectContaining({ + headers: expect.objectContaining({ + 'Authorization': 'Bearer oauth-token' + }) + }) + ); + }); +}); diff --git a/tests/storage-helpers.test.js b/tests/storage-helpers.test.js index 1dd9074..bbbfab6 100644 --- a/tests/storage-helpers.test.js +++ b/tests/storage-helpers.test.js @@ -22,6 +22,9 @@ const { setSyncItem, setLocalItem, getExcludedRepos, + getAuthSession, + setAuthSession, + clearAuthSession, getToken, setToken, clearToken @@ -30,6 +33,7 @@ const { describe('Storage Helpers', () => { let mockSyncStorage; let mockLocalStorage; + let mockSessionStorage; beforeEach(() => { // Reset mocks @@ -38,6 +42,7 @@ describe('Storage Helpers', () => { // Mock storage with in-memory objects mockSyncStorage = {}; mockLocalStorage = {}; + mockSessionStorage = {}; // Mock chrome.storage.sync global.chrome = { @@ -91,6 +96,29 @@ describe('Storage Helpers', () => { resolve(); }); }) + }, + session: { + get: jest.fn((keys, callback) => { + const result = {}; + const keyArray = Array.isArray(keys) ? keys : [keys]; + keyArray.forEach(key => { + if (Object.prototype.hasOwnProperty.call(mockSessionStorage, key)) { + result[key] = mockSessionStorage[key]; + } + }); + callback(result); + }), + set: jest.fn((items, callback) => { + Object.assign(mockSessionStorage, items); + if (callback) callback(); + }), + remove: jest.fn((keys, callback) => { + const keyArray = Array.isArray(keys) ? keys : [keys]; + keyArray.forEach(key => { + delete mockSessionStorage[key]; + }); + if (callback) callback(); + }) } } }; @@ -381,6 +409,69 @@ describe('Storage Helpers', () => { }); }); + describe('auth session helpers', () => { + it('returns auth session from encrypted local storage', async () => { + mockLocalStorage.encryptedGithubAuthSession = { iv: [], data: [] }; + mockDecryptData.mockResolvedValueOnce(JSON.stringify({ + accessToken: 'oauth-token', + username: 'octocat' + })); + + const result = await getAuthSession(); + + expect(result).toEqual({ + accessToken: 'oauth-token', + username: 'octocat' + }); + expect(mockSessionStorage.githubAuthSession).toEqual({ + accessToken: 'oauth-token', + username: 'octocat' + }); + }); + + it('returns auth session from session cache when available', async () => { + mockSessionStorage.githubAuthSession = { + accessToken: 'cached-token', + username: 'cached-user' + }; + + const result = await getAuthSession(); + + expect(result).toEqual({ + accessToken: 'cached-token', + username: 'cached-user' + }); + expect(mockDecryptData).not.toHaveBeenCalled(); + }); + + it('stores auth session in session and encrypted local storage', async () => { + await setAuthSession({ + accessToken: 'oauth-token', + username: 'octocat' + }); + + expect(mockSessionStorage.githubAuthSession).toEqual({ + accessToken: 'oauth-token', + username: 'octocat' + }); + expect(mockEncryptData).toHaveBeenCalledWith(JSON.stringify({ + accessToken: 'oauth-token', + username: 'octocat' + })); + expect(mockLocalStorage.encryptedGithubAuthSession).toBeDefined(); + }); + + it('clears auth session from all storage', async () => { + mockSessionStorage.githubAuthSession = { accessToken: 'oauth-token' }; + mockLocalStorage.encryptedGithubAuthSession = { iv: [], data: [] }; + + await clearAuthSession(); + + expect(mockSessionStorage.githubAuthSession).toBeUndefined(); + expect(mockLocalStorage.encryptedGithubAuthSession).toBeNull(); + }); + }); + describe('setToken', () => { it('should set token in local storage', async () => { await setToken('new-token-789'); From e951cdd4b82fe92502cb202535104d2854ad82ce Mon Sep 17 00:00:00 2001 From: jonmartin721 Date: Wed, 11 Mar 2026 16:43:48 -0500 Subject: [PATCH 02/11] switch github api auth to oauth sessions --- background.js | 5 ++--- options/controllers/import-controller.js | 4 ++-- options/options.js | 4 ++-- popup/views/onboarding-view.js | 20 +++++++++----------- shared/github-api.js | 4 ++-- shared/onboarding.js | 18 +++++++----------- shared/storage-helpers.js | 14 ++++++++++++++ tests/github-api.test.js | 6 +++--- tests/import-controller.test.js | 9 +++++---- tests/onboarding.test.js | 4 ++-- 10 files changed, 48 insertions(+), 40 deletions(-) diff --git a/background.js b/background.js index f687fb5..4c8133f 100644 --- a/background.js +++ b/background.js @@ -1,5 +1,5 @@ import { createHeaders, handleApiResponse, mapActivity, filterActivitiesByDate } from './shared/github-api.js'; -import { getSyncItems, getLocalItems, setLocalItem, getExcludedRepos, getToken, getFilteringSettings } from './shared/storage-helpers.js'; +import { getSyncItems, getLocalItems, setLocalItem, getExcludedRepos, getAccessToken, getFilteringSettings } from './shared/storage-helpers.js'; import { extractRepoName } from './shared/repository-utils.js'; import { safelyOpenUrl } from './shared/security.js'; @@ -73,8 +73,7 @@ if (typeof chrome !== 'undefined' && chrome.notifications) { async function checkGitHubActivity() { try { - // Get token from secure local storage - const githubToken = await getToken(); + const githubToken = await getAccessToken(); const { watchedRepos, lastCheck, filters, notifications, mutedRepos, snoozedRepos, unmutedRepos } = await getSyncItems([ 'watchedRepos', diff --git a/options/controllers/import-controller.js b/options/controllers/import-controller.js index d355295..f72d083 100644 --- a/options/controllers/import-controller.js +++ b/options/controllers/import-controller.js @@ -1,4 +1,4 @@ -import { getToken } from '../../shared/storage-helpers.js'; +import { getAccessToken } from '../../shared/storage-helpers.js'; import { createHeaders } from '../../shared/github-api.js'; import { escapeHtml, unescapeHtml } from '../../shared/sanitize.js'; import { formatDateVerbose } from '../../shared/utils.js'; @@ -62,7 +62,7 @@ function formatNumber(num) { } export async function openImportModal(type, watchedRepos) { - const token = await getToken(); + const token = await getAccessToken(); if (!token) { return; } diff --git a/options/options.js b/options/options.js index 835067c..b426b1d 100644 --- a/options/options.js +++ b/options/options.js @@ -1,5 +1,5 @@ import { applyTheme, formatDateVerbose } from '../shared/utils.js'; -import { getToken, setToken, clearToken as clearStoredToken, getLocalItems, setLocalItem } from '../shared/storage-helpers.js'; +import { getToken, getAccessToken, setToken, clearToken as clearStoredToken, getLocalItems, setLocalItem } from '../shared/storage-helpers.js'; import { createHeaders } from '../shared/github-api.js'; import { STORAGE_CONFIG, VALIDATION_PATTERNS } from '../shared/config.js'; import { validateRepository } from '../shared/repository-validator.js'; @@ -859,7 +859,7 @@ function showRepoError(message) { * // Returns: null */ async function validateRepo(repo) { - const githubToken = await getToken(); + const githubToken = await getAccessToken(); if (!githubToken) { return { valid: false, error: 'No GitHub token found. Please add a token first.' }; diff --git a/popup/views/onboarding-view.js b/popup/views/onboarding-view.js index 5dae119..3c35fd5 100644 --- a/popup/views/onboarding-view.js +++ b/popup/views/onboarding-view.js @@ -1,6 +1,6 @@ import { fetchGitHubRepoFromNpm } from '../../shared/api/npm-api.js'; import { OnboardingManager } from '../../shared/onboarding.js'; -import { getToken, setToken } from '../../shared/storage-helpers.js'; +import { getToken, getAccessToken, setToken } from '../../shared/storage-helpers.js'; import { createHeaders } from '../../shared/github-api.js'; import { escapeHtml } from '../../shared/sanitize.js'; @@ -619,8 +619,10 @@ function attachRepoButtonListeners() { try { // Fetch full repo metadata from GitHub API - const token = await getToken(); - const headers = createHeaders(token); + const token = await getAccessToken(); + const headers = token + ? createHeaders(token) + : { 'Accept': 'application/vnd.github.v3+json' }; const response = await fetch(`https://api.github.com/repos/${repo}`, { headers }); if (response.ok) { @@ -697,7 +699,7 @@ function setupReposStepListeners() { try { // Get token for API calls - const githubToken = await getToken(); + const githubToken = await getAccessToken(); // Parse GitHub URL if provided const urlMatch = repo.match(/github\.com\/([^/]+\/[^/]+)/); @@ -724,13 +726,9 @@ function setupReposStepListeners() { } // Validate repo exists on GitHub - const headers = { - 'Accept': 'application/vnd.github.v3+json' - }; - - if (githubToken) { - headers['Authorization'] = `token ${githubToken}`; - } + const headers = githubToken + ? createHeaders(githubToken) + : { 'Accept': 'application/vnd.github.v3+json' }; const response = await fetch(`https://api.github.com/repos/${repo}`, { headers }); diff --git a/shared/github-api.js b/shared/github-api.js index a4032bf..dfc561d 100644 --- a/shared/github-api.js +++ b/shared/github-api.js @@ -4,12 +4,12 @@ /** * Create standard GitHub API headers - * @param {string} token - GitHub personal access token + * @param {string} token - GitHub access token * @returns {Object} Headers object for fetch */ export function createHeaders(token) { return { - 'Authorization': `token ${token}`, + 'Authorization': `Bearer ${token}`, 'Accept': 'application/vnd.github.v3+json' }; } diff --git a/shared/onboarding.js b/shared/onboarding.js index 1da5d50..4529f2f 100644 --- a/shared/onboarding.js +++ b/shared/onboarding.js @@ -3,7 +3,8 @@ * Handles first-run experience and setup flow */ -import { getToken } from './storage-helpers.js'; +import { getAccessToken } from './storage-helpers.js'; +import { createHeaders } from './github-api.js'; export class OnboardingManager { static STORAGE_KEY = 'onboarding_state'; @@ -134,16 +135,11 @@ export class OnboardingManager { // by UI code that persists the token. Fallback to getToken() to // support tokens set outside onboarding. const tokenStep = await this.getStepData('token'); - const storedToken = await getToken(); + const storedToken = await getAccessToken(); const githubToken = tokenStep?.token || storedToken; - - const headers = { - 'Accept': 'application/vnd.github.v3+json' - }; - - if (githubToken) { - headers['Authorization'] = `token ${githubToken}`; - } + const headers = githubToken + ? createHeaders(githubToken) + : { 'Accept': 'application/vnd.github.v3+json' }; const apiUrl = 'https://api.github.com/search/repositories?q=stars:1000..50000&sort=stars&order=desc&per_page=20'; @@ -369,4 +365,4 @@ export class OnboardingManager { } } -export default OnboardingManager; \ No newline at end of file +export default OnboardingManager; diff --git a/shared/storage-helpers.js b/shared/storage-helpers.js index d065e2f..dd89f4b 100644 --- a/shared/storage-helpers.js +++ b/shared/storage-helpers.js @@ -242,6 +242,20 @@ export async function clearAuthSession() { await setLocalItem(AUTH_SESSION_STORAGE_KEY, null); } +/** + * Get the access token used for GitHub API requests + * Prefers the OAuth auth session when present. + * @returns {Promise} Access token or null + */ +export async function getAccessToken() { + const authSession = await getAuthSession(); + if (authSession?.accessToken) { + return authSession.accessToken; + } + + return getToken(); +} + /** * Get GitHub token * Tries session storage first (decrypted cache), then local storage (encrypted) diff --git a/tests/github-api.test.js b/tests/github-api.test.js index d0c3527..472a291 100644 --- a/tests/github-api.test.js +++ b/tests/github-api.test.js @@ -16,7 +16,7 @@ describe('GitHub API Helpers', () => { const headers = createHeaders(token); expect(headers).toEqual({ - 'Authorization': 'token ghp_test1234567890', + 'Authorization': 'Bearer ghp_test1234567890', 'Accept': 'application/vnd.github.v3+json' }); }); @@ -25,7 +25,7 @@ describe('GitHub API Helpers', () => { const headers = createHeaders(''); expect(headers).toEqual({ - 'Authorization': 'token ', + 'Authorization': 'Bearer ', 'Accept': 'application/vnd.github.v3+json' }); }); @@ -34,7 +34,7 @@ describe('GitHub API Helpers', () => { const token = 'github_pat_123ABC'; const headers = createHeaders(token); - expect(headers.Authorization).toBe('token github_pat_123ABC'); + expect(headers.Authorization).toBe('Bearer github_pat_123ABC'); }); }); diff --git a/tests/import-controller.test.js b/tests/import-controller.test.js index ea18312..1ffd163 100644 --- a/tests/import-controller.test.js +++ b/tests/import-controller.test.js @@ -2,11 +2,12 @@ import { jest, describe, test, beforeEach, expect } from '@jest/globals'; // Mock dependencies jest.unstable_mockModule('../shared/storage-helpers.js', () => ({ - getToken: jest.fn(() => Promise.resolve('fake-token')) + getToken: jest.fn(() => Promise.resolve('fake-token')), + getAccessToken: jest.fn(() => Promise.resolve('fake-token')) })); jest.unstable_mockModule('../shared/github-api.js', () => ({ - createHeaders: jest.fn((token) => ({ 'Authorization': `token ${token}` })) + createHeaders: jest.fn((token) => ({ 'Authorization': `Bearer ${token}` })) })); jest.unstable_mockModule('../shared/sanitize.js', () => ({ @@ -23,7 +24,7 @@ jest.unstable_mockModule('../shared/icons.js', () => ({ createSvg: jest.fn(() => '') })); -const { getToken } = await import('../shared/storage-helpers.js'); +const { getToken, getAccessToken } = await import('../shared/storage-helpers.js'); const { openImportModal, closeImportModal, @@ -114,7 +115,7 @@ describe('import-controller', () => { describe('openImportModal', () => { test('does not open modal if no token', async () => { - getToken.mockResolvedValueOnce(null); + getAccessToken.mockResolvedValueOnce(null); await openImportModal('starred', []); diff --git a/tests/onboarding.test.js b/tests/onboarding.test.js index b44dbf8..2fc7177 100644 --- a/tests/onboarding.test.js +++ b/tests/onboarding.test.js @@ -184,7 +184,7 @@ describe('Onboarding - token persistence', () => { // Ensure fetch was called with Authorization header expect(mockFetch).toHaveBeenCalled(); const [, options] = mockFetch.mock.calls[0]; - expect(options.headers['Authorization']).toBe('token ghp_TEST_TOKEN'); + expect(options.headers['Authorization']).toBe('Bearer ghp_TEST_TOKEN'); // Should return at least 1 repo from our mocked response expect(result.length).toBeGreaterThan(0); @@ -212,7 +212,7 @@ describe('Onboarding - token persistence', () => { expect(mockFetch).toHaveBeenCalled(); const [, options] = mockFetch.mock.calls[0]; - expect(options.headers['Authorization']).toBe('token ghp_STEP_TOKEN'); + expect(options.headers['Authorization']).toBe('Bearer ghp_STEP_TOKEN'); expect(result.length).toBeGreaterThan(0); }); From 9bbff083b5a71c1ab6544efbd5e68e17ea4748e3 Mon Sep 17 00:00:00 2001 From: jonmartin721 Date: Wed, 11 Mar 2026 16:54:45 -0500 Subject: [PATCH 03/11] replace token setup with github sign in --- options/controllers/token-controller.js | 245 ++++++++++-------- options/options.css | 10 + options/options.html | 78 +++--- options/options.js | 85 ++----- popup/views/onboarding-view.js | 163 +++++------- shared/auth.js | 20 ++ tests/onboarding.test.js | 137 +++++++--- tests/options-main.test.js | 195 +++++---------- tests/options-token-controller.test.js | 316 ++++++++---------------- 9 files changed, 546 insertions(+), 703 deletions(-) diff --git a/options/controllers/token-controller.js b/options/controllers/token-controller.js index 3d3d6bf..2389cae 100644 --- a/options/controllers/token-controller.js +++ b/options/controllers/token-controller.js @@ -1,138 +1,165 @@ -import { createHeaders } from '../../shared/github-api.js'; -import { clearToken as clearStoredToken } from '../../shared/storage-helpers.js'; +import { completeGitHubDeviceAuth } from '../../shared/auth.js'; +import { + clearAuthSession, + clearToken as clearLegacyToken, + getAuthSession, + setAuthSession +} from '../../shared/storage-helpers.js'; +import { OAUTH_CONFIG } from '../../shared/config.js'; import { NotificationManager } from '../../shared/ui/notification-manager.js'; const notifications = NotificationManager.getInstance(); -export async function clearToken() { - if (!confirm('Are you sure you want to clear your GitHub token?')) { - return false; - } - - document.getElementById('githubToken').value = ''; - document.getElementById('tokenStatus').textContent = ''; - document.getElementById('tokenStatus').className = 'token-status'; - document.getElementById('clearTokenBtn').style.display = 'none'; - +function setRepoAccessState(isConnected) { const repoInput = document.getElementById('repoInput'); - repoInput.disabled = true; - repoInput.placeholder = 'Enter a valid GitHub token to add repositories'; - document.getElementById('addRepoBtn').disabled = true; - document.getElementById('repoHelpText').textContent = 'Add a valid GitHub token above to start adding repositories'; + const addRepoBtn = document.getElementById('addRepoBtn'); + const repoHelpText = document.getElementById('repoHelpText'); + const importSection = document.getElementById('importReposSection'); + + repoInput.disabled = !isConnected; + repoInput.placeholder = isConnected + ? 'e.g., react, facebook/react, or GitHub URL' + : 'Connect GitHub to add repositories'; + addRepoBtn.disabled = !isConnected; + repoHelpText.textContent = isConnected + ? 'Add repositories to monitor (npm package, owner/repo, or GitHub URL)' + : 'Connect GitHub above to start adding repositories'; + importSection.classList.toggle('hidden', !isConnected); + importSection.style.display = isConnected ? 'block' : 'none'; +} - document.getElementById('importReposSection').style.display = 'none'; +function setDeviceCode(userCode = '') { + const deviceCodeInput = document.getElementById('githubToken'); + const deviceCodeSection = document.getElementById('deviceCodeSection'); - await clearStoredToken(); + if (!deviceCodeInput || !deviceCodeSection) { + return; + } - notifications.info('GitHub token cleared successfully'); - return true; + deviceCodeInput.value = userCode; + deviceCodeSection.classList.toggle('hidden', !userCode); + deviceCodeSection.style.display = userCode ? 'block' : 'none'; } -export async function validateToken(token, toastManager, options = {}) { +function setStatus(message = '', statusClass = '') { const statusEl = document.getElementById('tokenStatus'); - const shouldApplyResult = options.shouldApplyResult ?? (() => true); - try { - const response = await fetch('https://api.github.com/user', { - headers: createHeaders(token) - }); + if (!statusEl) { + return; + } - if (response.ok) { - const user = await response.json(); - if (!shouldApplyResult()) { - return { isValid: true, user: user.login }; - } + statusEl.textContent = message; + statusEl.className = `token-status${statusClass ? ` ${statusClass}` : ''}`; +} - statusEl.textContent = `✓ Valid (${user.login})`; - statusEl.className = 'token-status valid'; - document.getElementById('clearTokenBtn').style.display = 'block'; - - const repoInput = document.getElementById('repoInput'); - repoInput.disabled = false; - repoInput.placeholder = 'e.g., react, facebook/react, or GitHub URL'; - document.getElementById('addRepoBtn').disabled = false; - document.getElementById('repoHelpText').textContent = 'Add repositories to monitor (npm package, owner/repo, or GitHub URL)'; - - const importSection = document.getElementById('importReposSection'); - importSection.classList.remove('hidden'); - importSection.style.display = 'block'; - - if (!toastManager.lastValidToken || toastManager.lastValidToken !== token) { - if (toastManager.isManualTokenEntry) { - notifications.success(`GitHub token validated successfully for user: ${user.login}`); - } - toastManager.lastValidToken = token; - } +export function applyStoredConnection(authSession, options = {}) { + const connectBtn = document.getElementById('connectGitHubBtn'); + const clearBtn = document.getElementById('clearTokenBtn'); + const isConnected = Boolean(authSession?.accessToken); + const username = authSession?.username; - toastManager.isManualTokenEntry = false; - return { isValid: true, user: user.login }; - } else if (response.status === 401) { - if (!shouldApplyResult()) { - return { isValid: false, reason: 'invalid' }; - } + setRepoAccessState(isConnected); + setDeviceCode(options.userCode || ''); - statusEl.textContent = '✗ Invalid token'; - statusEl.className = 'token-status invalid'; - document.getElementById('clearTokenBtn').style.display = 'none'; + if (connectBtn) { + connectBtn.disabled = false; + connectBtn.textContent = isConnected ? 'Reconnect GitHub' : 'Connect GitHub'; + } - const repoInput = document.getElementById('repoInput'); - repoInput.disabled = true; - repoInput.placeholder = 'Enter a valid GitHub token to add repositories'; - document.getElementById('addRepoBtn').disabled = true; - document.getElementById('repoHelpText').textContent = 'Invalid token. Please check your GitHub token and try again.'; + if (clearBtn) { + clearBtn.style.display = isConnected ? 'block' : 'none'; + } - const importSection = document.getElementById('importReposSection'); - importSection.classList.add('hidden'); - importSection.style.display = 'none'; + if (options.statusMessage) { + setStatus(options.statusMessage, options.statusClass); + return; + } - if (!toastManager.lastInvalidToken || toastManager.lastInvalidToken !== token) { - notifications.error('Invalid GitHub token. Please check your token and try again.'); - toastManager.lastInvalidToken = token; - } - return { isValid: false, reason: 'invalid' }; - } else { - if (!shouldApplyResult()) { - return { isValid: false, reason: 'http', status: response.status }; - } + if (isConnected) { + setStatus( + username ? `Connected as ${username}` : 'GitHub is connected', + 'valid' + ); + } else { + setStatus('', ''); + } +} - statusEl.textContent = `✗ Error (${response.status})`; - statusEl.className = 'token-status invalid'; - document.getElementById('clearTokenBtn').style.display = 'none'; +export async function clearToken() { + if (!confirm('Disconnect GitHub from DevWatch?')) { + return false; + } - const repoInput = document.getElementById('repoInput'); - repoInput.disabled = true; - repoInput.placeholder = 'Enter a valid GitHub token to add repositories'; - document.getElementById('addRepoBtn').disabled = true; - document.getElementById('repoHelpText').textContent = 'GitHub API error. Please try again later.'; + await clearAuthSession(); + await clearLegacyToken(); - const importSection = document.getElementById('importReposSection'); - importSection.classList.add('hidden'); - importSection.style.display = 'none'; + applyStoredConnection(null); + notifications.info('GitHub disconnected'); + return true; +} - if (!toastManager.lastApiError || toastManager.lastApiError !== response.status) { - notifications.error(`GitHub API error (${response.status}). Please try again later.`); - toastManager.lastApiError = response.status; - } - return { isValid: false, reason: 'http', status: response.status }; - } - } catch (_error) { - if (!shouldApplyResult()) { - return { isValid: false, reason: 'network' }; - } +function getErrorMessage(error) { + switch (error?.code) { + case 'access_denied': + return 'GitHub sign-in was cancelled before access was granted.'; + case 'expired_token': + return 'The GitHub sign-in code expired. Start again to reconnect.'; + case 'aborted': + return 'GitHub sign-in was cancelled.'; + default: + return 'GitHub sign-in failed. Try again in a moment.'; + } +} - statusEl.textContent = '✗ Network error'; - statusEl.className = 'token-status invalid'; - document.getElementById('clearTokenBtn').style.display = 'none'; +export async function connectGitHub(_toastManager) { + const previousSession = await getAuthSession(); + const connectBtn = document.getElementById('connectGitHubBtn'); + let nextButtonLabel = previousSession?.accessToken ? 'Reconnect GitHub' : 'Connect GitHub'; - const repoInput = document.getElementById('repoInput'); - repoInput.disabled = true; - repoInput.placeholder = 'Enter a valid GitHub token to add repositories'; - document.getElementById('addRepoBtn').disabled = true; - document.getElementById('repoHelpText').textContent = 'Network error. Please check your connection and try again.'; + if (connectBtn) { + connectBtn.disabled = true; + connectBtn.textContent = 'Waiting for GitHub...'; + } - document.getElementById('importReposSection').style.display = 'none'; + setRepoAccessState(Boolean(previousSession?.accessToken)); + setStatus('Starting GitHub sign-in...', 'checking'); - notifications.error('Network error while validating token. Please check your connection and try again.'); - return { isValid: false, reason: 'network' }; + try { + const result = await completeGitHubDeviceAuth({ + onCode: ({ userCode }) => { + setDeviceCode(userCode || ''); + setStatus(`Enter ${userCode} on GitHub to finish connecting.`, 'checking'); + } + }); + + await setAuthSession(result.authSession); + await clearLegacyToken(); + + applyStoredConnection(result.authSession); + nextButtonLabel = 'Reconnect GitHub'; + notifications.success(`Connected to GitHub as ${result.user.login}`); + return { isValid: true, user: result.user.login, authSession: result.authSession }; + } catch (error) { + applyStoredConnection(previousSession, { + statusMessage: getErrorMessage(error), + statusClass: 'invalid' + }); + + if (previousSession?.accessToken) { + notifications.warning(getErrorMessage(error)); + } else { + notifications.error(getErrorMessage(error)); + } + + return { isValid: false, reason: error?.code || 'auth_failed' }; + } finally { + if (connectBtn) { + connectBtn.disabled = false; + connectBtn.textContent = nextButtonLabel; + } } } + +export function getDisconnectHelpUrl() { + return OAUTH_CONFIG.AUTHORIZED_APPS_URL; +} diff --git a/options/options.css b/options/options.css index 7d20bb8..f1d0ff8 100644 --- a/options/options.css +++ b/options/options.css @@ -2199,6 +2199,16 @@ body.dark-mode .notification-toggle input:checked + .toggle-slider { flex: 1; } +.auth-button-row { + flex-wrap: wrap; +} + +#deviceCodeSection input { + letter-spacing: 0.12em; + font-weight: 600; + text-align: center; +} + .token-status { display: block; font-size: 12px; diff --git a/options/options.html b/options/options.html index d0704b0..b44c6be 100644 --- a/options/options.html +++ b/options/options.html @@ -65,44 +65,46 @@

Getting Started

1
-
-

Create a GitHub Token

-

This allows the extension to fetch your repository activity securely.

-
-
- -
- -
- - -
- -
- -

- → Create a token with recommended permissions or - manually create one with repo and read:user scopes. -

- -
-
-

- - Security: Your token is encrypted with AES-GCM encryption and stored securely on your device. It's never transmitted to third parties. -

-
-
-

+

+

Connect GitHub

+

Sign in once so DevWatch can check the repositories you want to follow.

+
+
+ +
+ +
+ + +
+ + +
+ +

+ Click Connect GitHub, approve access on GitHub, and come back here once the browser tab says you can close it. +

+ +
+
+

- Expiration: Tokens expire after 90 days by default. You'll need to create a new token when yours expires, or select "No expiration" when creating your token (not recommended for security). -

-
-
+ + + Security: Your GitHub sign-in session is encrypted with AES-GCM and stored locally on your device. +

+
+
+

+ + Permissions: DevWatch requests repo and read:user access so it can read repository activity and identify your account. +

+
+
@@ -153,7 +155,7 @@

View Help & Changelog

Add New Repository

-

Add a valid GitHub token above to start adding repositories

+

Connect GitHub above to start adding repositories

diff --git a/options/options.js b/options/options.js index b426b1d..1e7b900 100644 --- a/options/options.js +++ b/options/options.js @@ -1,5 +1,5 @@ import { applyTheme, formatDateVerbose } from '../shared/utils.js'; -import { getToken, getAccessToken, setToken, clearToken as clearStoredToken, getLocalItems, setLocalItem } from '../shared/storage-helpers.js'; +import { getAuthSession, getAccessToken, getLocalItems, setLocalItem } from '../shared/storage-helpers.js'; import { createHeaders } from '../shared/github-api.js'; import { STORAGE_CONFIG, VALIDATION_PATTERNS } from '../shared/config.js'; import { validateRepository } from '../shared/repository-validator.js'; @@ -8,7 +8,7 @@ import { NotificationManager } from '../shared/ui/notification-manager.js'; // Controllers import { setupThemeListener } from './controllers/theme-controller.js'; -import { clearToken, validateToken } from './controllers/token-controller.js'; +import { applyStoredConnection, clearToken, connectGitHub } from './controllers/token-controller.js'; import { toggleMuteRepo, togglePinRepo } from './controllers/repository-controller.js'; import { openImportModal, closeImportModal, filterImportRepos, importSelectedRepos, updateSelectedCount } from './controllers/import-controller.js'; import { exportSettings, handleImportFile } from './controllers/export-import-controller.js'; @@ -29,7 +29,7 @@ const state = { searchQuery: '', hidePinnedRepos: false }; -let persistedToken = null; +let persistedSession = null; if (typeof document !== 'undefined') { document.addEventListener('DOMContentLoaded', async () => { @@ -170,20 +170,22 @@ function handleUrlParameters() { function syncTokenUiWithStoredCredential(hasStoredToken) { const clearTokenBtn = document.getElementById('clearTokenBtn'); + const connectGitHubBtn = document.getElementById('connectGitHubBtn'); const repoInput = document.getElementById('repoInput'); const addRepoBtn = document.getElementById('addRepoBtn'); const repoHelpText = document.getElementById('repoHelpText'); const importSection = document.getElementById('importReposSection'); + connectGitHubBtn.textContent = hasStoredToken ? 'Reconnect GitHub' : 'Connect GitHub'; clearTokenBtn.style.display = hasStoredToken ? 'block' : 'none'; repoInput.disabled = !hasStoredToken; repoInput.placeholder = hasStoredToken ? 'e.g., react, facebook/react, or GitHub URL' - : 'Enter a valid GitHub token to add repositories'; + : 'Connect GitHub to add repositories'; addRepoBtn.disabled = !hasStoredToken; repoHelpText.textContent = hasStoredToken ? 'Add repositories to monitor (npm package, owner/repo, or GitHub URL)' - : 'Add a valid GitHub token above to start adding repositories'; + : 'Connect GitHub above to start adding repositories'; importSection.classList.toggle('hidden', !hasStoredToken); importSection.style.display = hasStoredToken ? 'block' : 'none'; } @@ -197,10 +199,16 @@ function setupEventListeners() { setupTabNavigation(); document.getElementById('addRepoBtn').addEventListener('click', addRepo); + document.getElementById('connectGitHubBtn').addEventListener('click', async () => { + const connectionResult = await connectGitHub(toastManager); + if (connectionResult.isValid) { + persistedSession = connectionResult.authSession; + } + }); document.getElementById('clearTokenBtn').addEventListener('click', async () => { const tokenCleared = await clearToken(); if (tokenCleared) { - persistedToken = null; + persistedSession = null; } }); @@ -276,53 +284,6 @@ function setupEventListeners() { } }); - // Validate and persist token only after the current input has been confirmed valid. - let tokenValidationTimeout; - let tokenValidationRequestId = 0; - document.getElementById('githubToken').addEventListener('input', async (e) => { - clearTimeout(tokenValidationTimeout); - const token = e.target.value.trim(); - tokenValidationRequestId++; - const validationId = tokenValidationRequestId; - - if (!token) { - document.getElementById('tokenStatus').textContent = ''; - document.getElementById('tokenStatus').className = 'token-status'; - syncTokenUiWithStoredCredential(Boolean(persistedToken)); - return; - } - - document.getElementById('tokenStatus').textContent = 'Checking...'; - document.getElementById('tokenStatus').className = 'token-status checking'; - - // Mark that this is a manual token entry for toast purposes - toastManager.isManualTokenEntry = true; - - tokenValidationTimeout = setTimeout(async () => { - const validationResult = await validateToken(token, toastManager, { - shouldApplyResult: () => - validationId === tokenValidationRequestId && - document.getElementById('githubToken')?.value.trim() === token - }); - - if (validationId !== tokenValidationRequestId) { - return; - } - - if (validationResult.isValid) { - await setToken(token); - persistedToken = token; - } else if (shouldClearStoredToken(validationResult)) { - await clearStoredToken(); - persistedToken = null; - } - - if (!validationResult.isValid && persistedToken) { - syncTokenUiWithStoredCredential(true); - } - }, 500); - }); - // Auto-save theme changes document.querySelectorAll('input[name="theme"]').forEach(radio => { radio.addEventListener('change', async (e) => { @@ -577,8 +538,7 @@ async function loadSettings() { return; } - // Get token from secure local storage (with automatic migration) - const githubToken = await getToken(); + const authSession = await getAuthSession(); const settings = await chrome.storage.sync.get([ 'watchedRepos', @@ -610,16 +570,9 @@ async function loadSettings() { const theme = settings.theme || 'system'; applyTheme(theme); - if (githubToken) { - persistedToken = githubToken; - document.getElementById('githubToken').value = githubToken; - document.getElementById('clearTokenBtn').style.display = 'block'; - // Validate existing token - const validationResult = await validateToken(githubToken, toastManager); - if (shouldClearStoredToken(validationResult)) { - await clearStoredToken(); - persistedToken = null; - } + if (authSession?.accessToken) { + persistedSession = authSession; + applyStoredConnection(authSession); } else { syncTokenUiWithStoredCredential(false); } @@ -1020,7 +973,6 @@ window.updateSelectedCount = updateSelectedCount; if (typeof module !== 'undefined' && module.exports) { module.exports = { state, - validateToken, addRepo, validateRepo, removeRepo, @@ -1240,7 +1192,6 @@ setInterval(async () => { export { state, setupTabNavigation, - validateToken, loadSettings, setupEventListeners, addRepo, diff --git a/popup/views/onboarding-view.js b/popup/views/onboarding-view.js index 3c35fd5..9e926a1 100644 --- a/popup/views/onboarding-view.js +++ b/popup/views/onboarding-view.js @@ -1,6 +1,7 @@ import { fetchGitHubRepoFromNpm } from '../../shared/api/npm-api.js'; +import { completeGitHubDeviceAuth } from '../../shared/auth.js'; import { OnboardingManager } from '../../shared/onboarding.js'; -import { getToken, getAccessToken, setToken } from '../../shared/storage-helpers.js'; +import { clearToken as clearLegacyToken, getAccessToken, setAuthSession } from '../../shared/storage-helpers.js'; import { createHeaders } from '../../shared/github-api.js'; import { escapeHtml } from '../../shared/sanitize.js'; @@ -197,48 +198,48 @@ function renderWelcomeStep() { } async function renderTokenStep() { - const tokenUrl = onboardingManager.getGitHubTokenUrl(); const tokenData = await onboardingManager.getStepData('token'); - // Check if we already have a validated token let statusHtml = ''; let buttonDisabled = ''; - let buttonText = 'Validate'; - const safeToken = escapeHtml(tokenData?.token || ''); - const safeTokenUrl = escapeHtml(tokenUrl); + let buttonText = 'Connect GitHub'; + const safeUserCode = escapeHtml(tokenData?.userCode || ''); if (tokenData && tokenData.validated && tokenData.username) { - statusHtml = getStatusMarkup('success', `✓ Token is valid! Logged in as ${tokenData.username}`); + statusHtml = getStatusMarkup('success', `Connected as ${tokenData.username}`); buttonDisabled = 'disabled'; - buttonText = 'Validated'; + buttonText = 'Connected'; } else if (tokenData && tokenData.validated) { - statusHtml = getStatusMarkup('success', '✓ Token is valid!'); + statusHtml = getStatusMarkup('success', 'GitHub is connected'); buttonDisabled = 'disabled'; - buttonText = 'Validated'; + buttonText = 'Connected'; + } else if (tokenData?.userCode) { + statusHtml = getStatusMarkup('loading', `Enter ${tokenData.userCode} on GitHub to finish connecting.`); } return `
-

Add your GitHub Token

-

We need a GitHub token to access repository activity.

+

Connect GitHub

+

We'll open GitHub in a new tab and show you the verification code here.

Quick setup:

    -
  1. Create a GitHub token
  2. -
  3. Copy the generated token
  4. -
  5. Paste it below
  6. +
  7. Click Connect GitHub
  8. +
  9. Approve access on the GitHub page that opens
  10. +
  11. Come back here once GitHub says you're done
@@ -250,7 +251,7 @@ async function renderTokenStep() { - Your token is encrypted with AES-GCM encryption and stored securely on your device. It's only used for GitHub API access and never shared. + Your GitHub sign-in session is encrypted with AES-GCM encryption and stored securely on your device. It's only used for GitHub API access and never shared.

`; } @@ -494,27 +495,11 @@ export async function setupOnboardingStepListeners(currentStep, loadActivitiesCa exitOnboarding(loadActivitiesCallback); }); - // Token validation logic if (currentStep === 'token') { - const tokenInput = document.getElementById('tokenInput'); - - // Initial validation state - const validateTokenInput = () => { - const token = tokenInput?.value?.trim(); - const isValid = token && token.length > 10 && (token.startsWith('ghp_') || token.startsWith('github_pat_') || token.length >= 20); - - if (nextBtn) { - nextBtn.disabled = !isValid; - } - - return isValid; - }; - - // Add input listener for real-time validation - tokenInput?.addEventListener('input', validateTokenInput); - - // Initial validation - validateTokenInput(); + const tokenData = await onboardingManager.getStepData('token'); + if (nextBtn) { + nextBtn.disabled = !tokenData?.validated; + } } // Step-specific listeners @@ -535,47 +520,52 @@ function setupTokenStepListeners() { const tokenInput = document.getElementById('tokenInput'); const validateBtn = document.getElementById('validateTokenBtn'); const tokenStatus = document.getElementById('tokenStatus'); + const nextBtn = document.getElementById('nextBtn'); validateBtn?.addEventListener('click', async () => { - const token = tokenInput.value.trim(); - if (!token) { - tokenStatus.innerHTML = getStatusMarkup('error', 'Please enter a token'); - return; - } - - tokenStatus.innerHTML = getStatusMarkup('loading', 'Validating token...'); + validateBtn.disabled = true; + tokenStatus.innerHTML = getStatusMarkup('loading', 'Starting GitHub sign-in...'); try { - // Test the token by making a simple API call - const response = await fetch('https://api.github.com/user', { - headers: { - 'Authorization': `token ${token}` + const result = await completeGitHubDeviceAuth({ + onCode: async ({ userCode }) => { + tokenInput.value = userCode || ''; + tokenStatus.innerHTML = getStatusMarkup('loading', `Enter ${userCode} on GitHub to finish connecting.`); + await onboardingManager.saveStepData('token', { + userCode, + validated: false, + authType: 'oauth_device' + }); } }); - if (response.ok) { - const userData = await response.json(); - const username = userData.login; - const tokenData = { token, validated: true, username }; - tokenStatus.innerHTML = getStatusMarkup('success', `✓ Token is valid! Logged in as ${username}`); - await onboardingManager.saveStepData('token', tokenData); - // Persist the token first so any calls which read it - // can rely on the token being present. This reduces the chance of - // unauthenticated fetches or hitting rate limits when prefetching. - await setToken(token); - try { - const popular = await onboardingManager.getPopularRepos(); - if (Array.isArray(popular) && popular.length > 0) { - await onboardingManager.saveStepData('popularRepos', popular); - } - } catch (_prefetchError) { - // Silently handle prefetch errors - not critical + await setAuthSession(result.authSession); + await clearLegacyToken(); + tokenStatus.innerHTML = getStatusMarkup('success', `Connected as ${result.user.login}`); + await onboardingManager.saveStepData('token', { + validated: true, + username: result.user.login, + authType: 'oauth_device' + }); + + try { + const popular = await onboardingManager.getPopularRepos(); + if (Array.isArray(popular) && popular.length > 0) { + await onboardingManager.saveStepData('popularRepos', popular); } - } else { - tokenStatus.innerHTML = getStatusMarkup('error', '✗ Invalid token'); + } catch (_prefetchError) { + // Silently handle prefetch errors - not critical + } + + validateBtn.textContent = 'Connected'; + if (nextBtn) { + nextBtn.disabled = false; } - } catch (_error) { - tokenStatus.innerHTML = getStatusMarkup('error', 'Error validating token'); + } catch (error) { + tokenStatus.innerHTML = getStatusMarkup('error', error?.code === 'access_denied' + ? 'GitHub sign-in was cancelled' + : 'GitHub sign-in failed'); + validateBtn.disabled = false; } }); } @@ -825,36 +815,15 @@ export async function handleNextStep() { // Save step data before proceeding switch (currentStep) { case 'token': { - const tokenInput = document.getElementById('tokenInput'); - const token = tokenInput?.value?.trim(); - - if (!token) { - // Show error and prevent navigation + const existing = await onboardingManager.getStepData('token') || {}; + if (!existing.validated) { const tokenStatus = document.getElementById('tokenStatus'); if (tokenStatus) { - tokenStatus.textContent = 'Please enter a GitHub token to continue.'; + tokenStatus.textContent = 'Connect GitHub to continue.'; tokenStatus.className = 'token-status error'; } - tokenInput?.focus(); - return; // Prevent navigation - } - - // Preserve existing validation status when saving token data - // This ensures that if the token was previously validated, returning to - // the token step will still show the success message. - const existing = await onboardingManager.getStepData('token') || {}; - await onboardingManager.saveStepData('token', { ...existing, token }); - // If token was validated, prefetch popular repos so step 2 shows them quickly - const validated = existing.validated; - if (validated) { - try { - const popular = await onboardingManager.getPopularRepos(); - if (Array.isArray(popular) && popular.length > 0) { - await onboardingManager.saveStepData('popularRepos', popular); - } - } catch (_prefetchError) { - // Silently fail - user can still manually search for repos - } + document.getElementById('validateTokenBtn')?.focus(); + return; } break; } diff --git a/shared/auth.js b/shared/auth.js index 6c1ff02..9f212b4 100644 --- a/shared/auth.js +++ b/shared/auth.js @@ -214,3 +214,23 @@ export async function fetchGitHubUser(accessToken) { return response.json(); } + +export async function completeGitHubDeviceAuth(options = {}) { + const deviceCodeData = await requestGitHubDeviceCode(); + + options.onCode?.(deviceCodeData); + openGitHubDevicePage(deviceCodeData); + + const tokenData = await pollForGitHubAccessToken(deviceCodeData, { + signal: options.signal, + onPoll: options.onPoll + }); + const user = await fetchGitHubUser(tokenData.accessToken); + + return { + deviceCodeData, + tokenData, + user, + authSession: createGitHubAuthSession(tokenData, user) + }; +} diff --git a/tests/onboarding.test.js b/tests/onboarding.test.js index 2fc7177..caf05c2 100644 --- a/tests/onboarding.test.js +++ b/tests/onboarding.test.js @@ -52,6 +52,14 @@ global.chrome = { _localStorage = { ..._localStorage, ...items }; if (callback) callback(); return Promise.resolve(); + }), + remove: jest.fn((keys, callback) => { + const keyArray = Array.isArray(keys) ? keys : [keys]; + keyArray.forEach((key) => { + delete _localStorage[key]; + }); + if (callback) callback(); + return Promise.resolve(); }) } }, @@ -83,6 +91,7 @@ async function renderTokenStep(stateOverrides = {}) { `; await _renderOnboardingStep(); + await Promise.resolve(); } describe('Onboarding - token persistence', () => { @@ -94,7 +103,7 @@ describe('Onboarding - token persistence', () => { completed: false, skippedSteps: [], data: { - token: { token: 'ghp_OLD', validated: true, username: 'alice' } + token: { validated: true, username: 'alice', authType: 'oauth_device' } } } }; @@ -129,11 +138,12 @@ describe('Onboarding - token persistence', () => { _renderOnboardingStep = module.renderOnboardingStep; }); - test('preserves validated flag when saving token and navigating next', async () => { + test('preserves validated auth state when navigating past the connect step', async () => { // Setup minimal DOM expected by handleNextStep document.body.innerHTML = ` - +
+
`; @@ -144,21 +154,17 @@ describe('Onboarding - token persistence', () => { expect(dataBefore.validated).toBe(true); expect(dataBefore.username).toBe('alice'); - // Call handleNextStep which should preserve existing validated info await _handleNextStep(); - // Read what's saved const result = await new Promise((resolve) => { chrome.storage.local.get(['onboarding_state'], (res) => resolve(res.onboarding_state)); }); - // The token object should have been merged with the existing validated info expect(result).toBeTruthy(); expect(result.data).toBeTruthy(); - expect(result.data.token.token).toBe('ghp_NEW'); - // Preserved expect(result.data.token.validated).toBe(true); expect(result.data.token.username).toBe('alice'); + expect(document.getElementById('tokenStatus').textContent).toBe(''); }); test('getPopularRepos uses stored token in request', async () => { @@ -259,11 +265,11 @@ describe('Onboarding - token persistence', () => { expect(html).not.toContain(''); }); - test('renderOnboardingStep escapes saved token values and usernames on the token step', async () => { + test('renderOnboardingStep escapes saved device codes and usernames on the connect step', async () => { await renderTokenStep({ data: { token: { - token: 'ghp_test" autofocus="true', + userCode: 'ABCD"" autofocus="true', validated: true, username: '' } @@ -274,18 +280,17 @@ describe('Onboarding - token persistence', () => { const tokenInput = document.getElementById('tokenInput'); const tokenStatus = document.getElementById('tokenStatus'); - expect(tokenInput.value).toBe('ghp_test" autofocus="true'); + expect(tokenInput.value).toBe('ABCD"" autofocus="true'); expect(tokenInput.outerHTML).toContain('"'); - expect(tokenStatus.textContent).toContain('Logged in as '); + expect(tokenStatus.textContent).toContain('Connected as '); expect(onboardingHtml).toContain('<img src=x onerror=alert(1)>'); expect(onboardingHtml).not.toContain(''); }); - test('renderOnboardingStep shows validated status without username safely', async () => { + test('renderOnboardingStep shows connected status without username safely', async () => { await renderTokenStep({ data: { token: { - token: 'ghp_token', validated: true } } @@ -294,50 +299,105 @@ describe('Onboarding - token persistence', () => { const onboardingHtml = document.getElementById('onboardingView').innerHTML; const tokenStatus = document.getElementById('tokenStatus'); - expect(tokenStatus.textContent).toContain('✓ Token is valid!'); - expect(onboardingHtml).toContain('Validated'); + expect(tokenStatus.textContent).toContain('GitHub is connected'); + expect(onboardingHtml).toContain('Connected'); }); - test('token step shows an error when validation is attempted with no token', async () => { + test('connect step shows device instructions when sign-in starts', async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ + device_code: 'device-code', + user_code: 'ABCD-EFGH', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0 + }) + }).mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ + error: 'authorization_pending' + }) + }); + await renderTokenStep(); document.getElementById('validateTokenBtn').click(); + await Promise.resolve(); + await Promise.resolve(); + await new Promise(resolve => setTimeout(resolve, 0)); - expect(document.getElementById('tokenStatus').textContent).toBe('Please enter a token'); - expect(global.fetch).not.toHaveBeenCalled(); + expect(document.getElementById('tokenInput').value).toBe('ABCD-EFGH'); + expect(document.getElementById('tokenStatus').textContent).toContain('ABCD-EFGH'); + expect(chrome.tabs.create).toHaveBeenCalledWith({ url: 'https://github.com/login/device' }); }); - test('token step escapes invalid-token responses', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: false, - status: 401 - }); + test('connect step handles cancelled sign-in', async () => { + global.fetch = jest.fn() + .mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ + device_code: 'device-code', + user_code: 'ABCD-EFGH', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0 + }) + }) + .mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ + error: 'access_denied' + }) + }); await renderTokenStep(); - - const tokenInput = document.getElementById('tokenInput'); - tokenInput.value = 'ghp_invalid'; document.getElementById('validateTokenBtn').click(); await Promise.resolve(); + await Promise.resolve(); + await new Promise(resolve => setTimeout(resolve, 0)); - expect(document.getElementById('tokenStatus').textContent).toBe('✗ Invalid token'); + expect(document.getElementById('tokenStatus').textContent).toBe('GitHub sign-in was cancelled'); }); - test('token step escapes network validation errors', async () => { + test('connect step handles device flow errors', async () => { global.fetch = jest.fn().mockRejectedValue(new Error('network down')); await renderTokenStep(); - const tokenInput = document.getElementById('tokenInput'); - tokenInput.value = 'ghp_network'; document.getElementById('validateTokenBtn').click(); await Promise.resolve(); + await new Promise(resolve => setTimeout(resolve, 0)); - expect(document.getElementById('tokenStatus').textContent).toBe('Error validating token'); + expect(document.getElementById('tokenStatus').textContent).toBe('GitHub sign-in failed'); }); - test('token step escapes successful validation messages', async () => { + test('connect step escapes successful sign-in messages', async () => { global.fetch = jest.fn(async (url) => { + if (url === 'https://github.com/login/device/code') { + return { + ok: true, + text: async () => JSON.stringify({ + device_code: 'device-code', + user_code: 'ABCD-EFGH', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0 + }) + }; + } + + if (url === 'https://github.com/login/oauth/access_token') { + return { + ok: true, + text: async () => JSON.stringify({ + access_token: 'oauth-token', + token_type: 'bearer', + scope: 'repo read:user' + }) + }; + } + if (url === 'https://api.github.com/user') { return { ok: true, @@ -345,22 +405,19 @@ describe('Onboarding - token persistence', () => { }; } - return { - ok: true, - json: async () => ({ items: [] }) - }; + return { ok: true, json: async () => ({ items: [] }) }; }); await renderTokenStep(); - const tokenInput = document.getElementById('tokenInput'); - tokenInput.value = 'ghp_valid'; document.getElementById('validateTokenBtn').click(); await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); + await new Promise(resolve => setTimeout(resolve, 0)); const tokenStatus = document.getElementById('tokenStatus'); - expect(tokenStatus.textContent).toContain('Logged in as '); + expect(tokenStatus.textContent).toContain('Connected as '); expect(tokenStatus.innerHTML).toContain('<img src=x onerror=alert(1)>'); }); diff --git a/tests/options-main.test.js b/tests/options-main.test.js index 086fe33..e9e0140 100644 --- a/tests/options-main.test.js +++ b/tests/options-main.test.js @@ -16,9 +16,12 @@ describe('Options Main Functions', () => { beforeEach(() => { // Setup complete DOM structure for options page document.body.innerHTML = ` - + +
- +
@@ -126,6 +129,9 @@ describe('Options Main Functions', () => { }, runtime: { sendMessage: jest.fn(() => Promise.resolve()) + }, + tabs: { + create: jest.fn() } }; @@ -295,8 +301,9 @@ describe('Options Main Functions', () => { expect(shouldClearStoredToken({ isValid: true, user: 'testuser' })).toBe(false); }); - test('restores authenticated UI when a stored token still exists', () => { + test('restores authenticated UI when a stored session exists', () => { const clearBtn = document.getElementById('clearTokenBtn'); + const connectBtn = document.getElementById('connectGitHubBtn'); const repoInput = document.getElementById('repoInput'); const addBtn = document.getElementById('addRepoBtn'); const helpText = document.getElementById('repoHelpText'); @@ -311,6 +318,7 @@ describe('Options Main Functions', () => { syncTokenUiWithStoredCredential(true); + expect(connectBtn.textContent).toBe('Reconnect GitHub'); expect(clearBtn.style.display).toBe('block'); expect(repoInput.disabled).toBe(false); expect(addBtn.disabled).toBe(false); @@ -321,6 +329,7 @@ describe('Options Main Functions', () => { test('restores unauthenticated UI when no stored token is available', () => { const clearBtn = document.getElementById('clearTokenBtn'); + const connectBtn = document.getElementById('connectGitHubBtn'); const repoInput = document.getElementById('repoInput'); const addBtn = document.getElementById('addRepoBtn'); const helpText = document.getElementById('repoHelpText'); @@ -328,17 +337,23 @@ describe('Options Main Functions', () => { syncTokenUiWithStoredCredential(false); + expect(connectBtn.textContent).toBe('Connect GitHub'); expect(clearBtn.style.display).toBe('none'); expect(repoInput.disabled).toBe(true); expect(addBtn.disabled).toBe(true); - expect(helpText.textContent).toContain('Add a valid GitHub token above'); + expect(helpText.textContent).toContain('Connect GitHub above'); expect(importSection.classList.contains('hidden')).toBe(true); expect(importSection.style.display).toBe('none'); }); - test('loadSettings preserves stored token on transient validation failures', async () => { + test('loadSettings restores a stored auth session', async () => { chrome.storage.session.get.mockImplementation((keys, callback) => { - callback({ githubToken: 'persisted-token' }); + callback({ + githubAuthSession: { + accessToken: 'persisted-token', + username: 'persisted-user' + } + }); }); chrome.storage.sync.get.mockImplementation((keys, callback) => { const result = Array.isArray(keys) && keys.includes('snoozedRepos') @@ -350,168 +365,76 @@ describe('Options Main Functions', () => { } return Promise.resolve(result); }); - global.fetch.mockResolvedValue({ - ok: false, - status: 500 - }); await loadSettings(); - expect(document.getElementById('githubToken').value).toBe('persisted-token'); - expect(document.getElementById('clearTokenBtn').style.display).toBe('none'); - expect(document.getElementById('repoInput').disabled).toBe(true); - expect(chrome.storage.local.set).not.toHaveBeenCalledWith( - expect.objectContaining({ encryptedGithubToken: null }), - expect.any(Function) - ); - }); - - test('loadSettings clears stored token when validation reports invalid credentials', async () => { - chrome.storage.session.get.mockImplementation((keys, callback) => { - callback({ githubToken: 'expired-token' }); - }); - chrome.storage.sync.get.mockImplementation((keys, callback) => { - const result = Array.isArray(keys) && keys.includes('snoozedRepos') - ? { snoozedRepos: [] } - : {}; - if (callback) { - callback(result); - return; - } - return Promise.resolve(result); - }); - global.fetch.mockResolvedValue({ - ok: false, - status: 401 - }); - - await loadSettings(); - - expect(chrome.storage.local.set).toHaveBeenCalledWith( - expect.objectContaining({ encryptedGithubToken: null }), - expect.any(Function) - ); + expect(document.getElementById('tokenStatus').textContent).toContain('persisted-user'); + expect(document.getElementById('connectGitHubBtn').textContent).toBe('Reconnect GitHub'); + expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); + expect(document.getElementById('repoInput').disabled).toBe(false); }); - test('setupEventListeners clears persisted token after the clear action succeeds', async () => { + test('setupEventListeners clears persisted auth after the disconnect action succeeds', async () => { setupEventListeners(); document.getElementById('clearTokenBtn').click(); await Promise.resolve(); - expect(chrome.storage.session.remove).toHaveBeenCalledWith(['githubToken'], expect.any(Function)); + expect(chrome.storage.session.remove).toHaveBeenCalledWith(['githubAuthSession'], expect.any(Function)); expect(chrome.storage.local.set).toHaveBeenCalledWith( - expect.objectContaining({ encryptedGithubToken: null }), + expect.objectContaining({ encryptedGithubAuthSession: null }), expect.any(Function) ); }); - test('empty token input restores stored-token UI state', async () => { - chrome.storage.session.get.mockImplementation((keys, callback) => { - callback({ githubToken: 'persisted-token' }); - }); - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ login: 'persisted-user' }) - }); - - await loadSettings(); + test('connect button starts the device flow and stores the session', async () => { setupEventListeners(); - const tokenInput = document.getElementById('githubToken'); - tokenInput.value = ''; - tokenInput.dispatchEvent(new Event('input', { bubbles: true })); - - expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); - expect(document.getElementById('repoInput').disabled).toBe(false); - }); - - test('token input persists newly validated replacement tokens', async () => { - jest.useFakeTimers(); - setupEventListeners(); - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ login: 'new-user' }) - }); - - const tokenInput = document.getElementById('githubToken'); - tokenInput.value = 'new-token'; - tokenInput.dispatchEvent(new Event('input', { bubbles: true })); - - await jest.advanceTimersByTimeAsync(500); - await Promise.resolve(); - - expect(chrome.storage.session.set).toHaveBeenCalledWith( - { githubToken: 'new-token' }, - expect.any(Function) - ); - expect(chrome.storage.local.set).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ encryptedGithubToken: expect.any(Object) }), - expect.any(Function) - ); - }); - - test('token input clears persisted credentials after confirmed invalid replacement', async () => { - jest.useFakeTimers(); - chrome.storage.session.get.mockImplementation((keys, callback) => { - callback({ githubToken: 'persisted-token' }); - }); global.fetch .mockResolvedValueOnce({ ok: true, - json: async () => ({ login: 'persisted-user' }) + text: async () => JSON.stringify({ + device_code: 'device-code', + user_code: 'ABCD-EFGH', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0 + }) }) - .mockResolvedValueOnce({ - ok: false, - status: 401 - }); - - await loadSettings(); - setupEventListeners(); - - const tokenInput = document.getElementById('githubToken'); - tokenInput.value = 'expired-token'; - tokenInput.dispatchEvent(new Event('input', { bubbles: true })); - - await jest.advanceTimersByTimeAsync(500); - await Promise.resolve(); - - expect(chrome.storage.session.remove).toHaveBeenCalledWith(['githubToken'], expect.any(Function)); - expect(chrome.storage.local.set).toHaveBeenCalledWith( - expect.objectContaining({ encryptedGithubToken: null }), - expect.any(Function) - ); - }); - - test('token input keeps repository controls enabled after transient replacement failure', async () => { - jest.useFakeTimers(); - chrome.storage.session.get.mockImplementation((keys, callback) => { - callback({ githubToken: 'persisted-token' }); - }); - global.fetch .mockResolvedValueOnce({ ok: true, - json: async () => ({ login: 'persisted-user' }) + text: async () => JSON.stringify({ + access_token: 'oauth-token', + token_type: 'bearer', + scope: 'repo read:user' + }) }) .mockResolvedValueOnce({ - ok: false, - status: 500 + ok: true, + json: async () => ({ login: 'new-user' }) }); - await loadSettings(); - setupEventListeners(); - - const tokenInput = document.getElementById('githubToken'); - tokenInput.value = 'replacement-token'; - tokenInput.dispatchEvent(new Event('input', { bubbles: true })); - - await jest.advanceTimersByTimeAsync(500); + document.getElementById('connectGitHubBtn').click(); + await Promise.resolve(); + await Promise.resolve(); await Promise.resolve(); + await new Promise(resolve => setTimeout(resolve, 0)); expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); expect(document.getElementById('repoInput').disabled).toBe(false); expect(document.getElementById('addRepoBtn').disabled).toBe(false); + expect(chrome.storage.session.set).toHaveBeenCalledWith( + expect.objectContaining({ + githubAuthSession: expect.objectContaining({ + accessToken: 'oauth-token', + username: 'new-user' + }) + }), + expect.any(Function) + ); + expect(chrome.tabs.create).toHaveBeenCalledWith({ + url: 'https://github.com/login/device' + }); }); }); diff --git a/tests/options-token-controller.test.js b/tests/options-token-controller.test.js index ada5f3e..58d3909 100644 --- a/tests/options-token-controller.test.js +++ b/tests/options-token-controller.test.js @@ -1,257 +1,141 @@ -import { jest, describe, test, beforeEach, expect } from '@jest/globals'; - -const { clearToken, validateToken } = await import('../options/controllers/token-controller.js'); +import { jest } from '@jest/globals'; + +const mockCompleteGitHubDeviceAuth = jest.fn(); +const mockClearAuthSession = jest.fn(() => Promise.resolve()); +const mockClearLegacyToken = jest.fn(() => Promise.resolve()); +const mockGetAuthSession = jest.fn(() => Promise.resolve(null)); +const mockSetAuthSession = jest.fn(() => Promise.resolve()); + +jest.unstable_mockModule('../shared/auth.js', () => ({ + completeGitHubDeviceAuth: mockCompleteGitHubDeviceAuth +})); + +jest.unstable_mockModule('../shared/storage-helpers.js', () => ({ + clearAuthSession: mockClearAuthSession, + clearToken: mockClearLegacyToken, + getAuthSession: mockGetAuthSession, + setAuthSession: mockSetAuthSession +})); + +const { + applyStoredConnection, + clearToken, + connectGitHub +} = await import('../options/controllers/token-controller.js'); describe('Token Controller', () => { beforeEach(() => { document.body.innerHTML = ` - + + +
-
-
+ `; - // Chrome mocks are provided by setup.js + jest.clearAllMocks(); global.confirm = jest.fn(() => true); - global.fetch = jest.fn(); }); - test('clearToken does nothing when cancelled', async () => { - global.confirm.mockReturnValue(false); - - await clearToken(); - - expect(document.getElementById('githubToken').value).toBe('test-token'); - }); - - test('validateToken handles valid token', async () => { - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ login: 'testuser' }) - }); - - const toastManager = {}; - const result = await validateToken('test-token', toastManager); + test('applyStoredConnection restores signed-out state', () => { + applyStoredConnection(null); - const statusEl = document.getElementById('tokenStatus'); - expect(statusEl.textContent).toContain('testuser'); - expect(result).toEqual({ isValid: true, user: 'testuser' }); - }); - - test('validateToken skips UI updates for stale responses', async () => { - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ login: 'stale-user' }) - }); - - document.getElementById('tokenStatus').textContent = 'Checking...'; - document.getElementById('tokenStatus').className = 'token-status checking'; - document.getElementById('clearTokenBtn').style.display = 'none'; - - const toastManager = { isManualTokenEntry: true }; - const result = await validateToken('stale-token', toastManager, { - shouldApplyResult: () => false - }); - - expect(result).toEqual({ isValid: true, user: 'stale-user' }); - expect(document.getElementById('tokenStatus').textContent).toBe('Checking...'); - expect(document.getElementById('tokenStatus').className).toBe('token-status checking'); + expect(document.getElementById('connectGitHubBtn').textContent).toBe('Connect GitHub'); expect(document.getElementById('clearTokenBtn').style.display).toBe('none'); - expect(toastManager.lastValidToken).toBeUndefined(); + expect(document.getElementById('repoInput').disabled).toBe(true); + expect(document.getElementById('addRepoBtn').disabled).toBe(true); + expect(document.getElementById('repoHelpText').textContent).toContain('Connect GitHub above'); }); - test('validateToken handles invalid token', async () => { - global.fetch.mockResolvedValue({ - ok: false, - status: 401 + test('applyStoredConnection restores connected state', () => { + applyStoredConnection({ + accessToken: 'oauth-token', + username: 'octocat' }); - const toastManager = {}; - const result = await validateToken('bad-token', toastManager); - - const statusEl = document.getElementById('tokenStatus'); - expect(statusEl.textContent).toContain('Invalid'); - expect(result).toEqual({ isValid: false, reason: 'invalid' }); - }); - - test('validateToken skips stale invalid-token UI updates', async () => { - global.fetch.mockResolvedValue({ - ok: false, - status: 401 - }); - - document.getElementById('tokenStatus').textContent = 'Checking...'; - document.getElementById('tokenStatus').className = 'token-status checking'; - document.getElementById('clearTokenBtn').style.display = 'block'; - - const toastManager = {}; - const result = await validateToken('stale-bad-token', toastManager, { - shouldApplyResult: () => false - }); - - expect(result).toEqual({ isValid: false, reason: 'invalid' }); - expect(document.getElementById('tokenStatus').textContent).toBe('Checking...'); - expect(document.getElementById('tokenStatus').className).toBe('token-status checking'); + expect(document.getElementById('connectGitHubBtn').textContent).toBe('Reconnect GitHub'); expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); - expect(toastManager.lastInvalidToken).toBeUndefined(); - }); - test('clearToken clears all fields when confirmed', async () => { - global.confirm.mockReturnValue(true); - - const tokenInput = document.getElementById('githubToken'); - const statusEl = document.getElementById('tokenStatus'); - const clearBtn = document.getElementById('clearTokenBtn'); - const repoInput = document.getElementById('repoInput'); - const addBtn = document.getElementById('addRepoBtn'); - const importSection = document.getElementById('importReposSection'); - - await clearToken(); - - expect(tokenInput.value).toBe(''); - expect(statusEl.textContent).toBe(''); - expect(clearBtn.style.display).toBe('none'); - expect(repoInput.disabled).toBe(true); - expect(addBtn.disabled).toBe(true); - expect(importSection.style.display).toBe('none'); - }); - - test('validateToken handles other HTTP errors', async () => { - global.fetch.mockResolvedValue({ - ok: false, - status: 500 - }); - - const toastManager = {}; - const result = await validateToken('token', toastManager); - - const statusEl = document.getElementById('tokenStatus'); - expect(statusEl.textContent).toContain('Error (500)'); - expect(statusEl.className).toContain('invalid'); - expect(result).toEqual({ isValid: false, reason: 'http', status: 500 }); + expect(document.getElementById('tokenStatus').textContent).toContain('octocat'); + expect(document.getElementById('repoInput').disabled).toBe(false); + expect(document.getElementById('importReposSection').style.display).toBe('block'); }); - test('validateToken skips stale API error UI updates', async () => { - global.fetch.mockResolvedValue({ - ok: false, - status: 500 - }); - - document.getElementById('tokenStatus').textContent = 'Checking...'; - document.getElementById('tokenStatus').className = 'token-status checking'; - document.getElementById('clearTokenBtn').style.display = 'block'; - - const toastManager = {}; - const result = await validateToken('stale-token', toastManager, { - shouldApplyResult: () => false - }); - - expect(result).toEqual({ isValid: false, reason: 'http', status: 500 }); - expect(document.getElementById('tokenStatus').textContent).toBe('Checking...'); - expect(document.getElementById('tokenStatus').className).toBe('token-status checking'); - expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); - expect(toastManager.lastApiError).toBeUndefined(); - }); - - test('validateToken handles network errors', async () => { - global.fetch.mockRejectedValue(new Error('Network error')); - - const toastManager = {}; - const result = await validateToken('token', toastManager); - - const statusEl = document.getElementById('tokenStatus'); - expect(statusEl.textContent).toContain('Network error'); - expect(statusEl.className).toContain('invalid'); - expect(result).toEqual({ isValid: false, reason: 'network' }); - }); - - test('validateToken skips stale network-error UI updates', async () => { - global.fetch.mockRejectedValue(new Error('Network error')); - - document.getElementById('tokenStatus').textContent = 'Checking...'; - document.getElementById('tokenStatus').className = 'token-status checking'; - document.getElementById('clearTokenBtn').style.display = 'block'; + test('clearToken does nothing when cancelled', async () => { + global.confirm.mockReturnValue(false); - const toastManager = {}; - const result = await validateToken('stale-token', toastManager, { - shouldApplyResult: () => false - }); + const result = await clearToken(); - expect(result).toEqual({ isValid: false, reason: 'network' }); - expect(document.getElementById('tokenStatus').textContent).toBe('Checking...'); - expect(document.getElementById('tokenStatus').className).toBe('token-status checking'); - expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); + expect(result).toBe(false); + expect(mockClearAuthSession).not.toHaveBeenCalled(); + expect(mockClearLegacyToken).not.toHaveBeenCalled(); }); - test('validateToken shows success toast only on first validation', async () => { - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ login: 'testuser' }) + test('clearToken clears auth state when confirmed', async () => { + applyStoredConnection({ + accessToken: 'oauth-token', + username: 'octocat' }); - const toastManager = { isManualTokenEntry: true }; - - await validateToken('new-token', toastManager); - expect(toastManager.lastValidToken).toBe('new-token'); - - // Second validation with same token shouldn't show toast - await validateToken('new-token', toastManager); - }); + const result = await clearToken(); - test('validateToken shows error toast only once per token', async () => { - global.fetch.mockResolvedValue({ - ok: false, - status: 401 + expect(result).toBe(true); + expect(mockClearAuthSession).toHaveBeenCalled(); + expect(mockClearLegacyToken).toHaveBeenCalled(); + expect(document.getElementById('clearTokenBtn').style.display).toBe('none'); + expect(document.getElementById('repoInput').disabled).toBe(true); + }); + + test('connectGitHub stores auth session after a successful device flow', async () => { + mockCompleteGitHubDeviceAuth.mockImplementation(async ({ onCode }) => { + await onCode({ userCode: 'ABCD-EFGH' }); + return { + user: { login: 'octocat' }, + authSession: { + accessToken: 'oauth-token', + username: 'octocat' + } + }; }); - const toastManager = {}; - - await validateToken('bad-token', toastManager); - expect(toastManager.lastInvalidToken).toBe('bad-token'); - }); + const result = await connectGitHub({}); - test('validateToken shows API error toast only once per status', async () => { - global.fetch.mockResolvedValue({ - ok: false, - status: 500 + expect(result).toEqual({ + isValid: true, + user: 'octocat', + authSession: { + accessToken: 'oauth-token', + username: 'octocat' + } }); - - const toastManager = {}; - - await validateToken('token', toastManager); - expect(toastManager.lastApiError).toBe(500); - }); - - test('validateToken enables import section on valid token', async () => { - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ login: 'testuser' }) + expect(mockSetAuthSession).toHaveBeenCalledWith({ + accessToken: 'oauth-token', + username: 'octocat' }); - - const toastManager = {}; - const importSection = document.getElementById('importReposSection'); - importSection.classList.add('hidden'); - - await validateToken('token', toastManager); - - expect(importSection.classList.contains('hidden')).toBe(false); - expect(importSection.style.display).toBe('block'); + expect(mockClearLegacyToken).toHaveBeenCalled(); + expect(document.getElementById('connectGitHubBtn').textContent).toBe('Reconnect GitHub'); + expect(document.getElementById('tokenStatus').textContent).toContain('octocat'); + expect(document.getElementById('githubToken').value).toBe(''); }); - test('validateToken disables import section on invalid token', async () => { - global.fetch.mockResolvedValue({ - ok: false, - status: 401 + test('connectGitHub keeps the existing session enabled when reconnect fails', async () => { + mockGetAuthSession.mockResolvedValueOnce({ + accessToken: 'existing-token', + username: 'existing-user' }); + mockCompleteGitHubDeviceAuth.mockRejectedValueOnce(Object.assign(new Error('cancelled'), { + code: 'access_denied' + })); - const toastManager = {}; - const importSection = document.getElementById('importReposSection'); + const result = await connectGitHub({}); - await validateToken('token', toastManager); - - expect(importSection.classList.contains('hidden')).toBe(true); - expect(importSection.style.display).toBe('none'); + expect(result).toEqual({ isValid: false, reason: 'access_denied' }); + expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); + expect(document.getElementById('repoInput').disabled).toBe(false); + expect(document.getElementById('tokenStatus').textContent).toContain('cancelled'); }); }); From 4bbbe9efbaadeebe0dad4097882c7cf6c558f482 Mon Sep 17 00:00:00 2001 From: jonmartin721 Date: Wed, 11 Mar 2026 16:59:11 -0500 Subject: [PATCH 04/11] update auth errors and disconnect handling --- background.js | 4 ++-- .../controllers/export-import-controller.js | 2 +- options/controllers/import-controller.js | 2 +- options/options.html | 2 +- options/options.js | 4 ++-- shared/config.js | 4 ++-- shared/error-handler.js | 17 +++++++++----- shared/github-api.js | 2 +- shared/onboarding.js | 17 ++++---------- shared/repository-validator.js | 10 ++++----- shared/storage-helpers.js | 2 +- tests/background.test.js | 22 +++++++++---------- tests/error-handler.test.js | 6 ++--- tests/github-api.test.js | 2 +- tests/import-controller.test.js | 2 +- tests/onboarding.test.js | 21 +++++++++--------- tests/options.test.js | 11 +++++----- tests/phase1.test.js | 6 ++--- tests/repository-validator.test.js | 2 +- 19 files changed, 68 insertions(+), 70 deletions(-) diff --git a/background.js b/background.js index 4c8133f..88d9e24 100644 --- a/background.js +++ b/background.js @@ -86,7 +86,7 @@ async function checkGitHubActivity() { ]); if (!githubToken) { - console.warn('[DevWatch] No GitHub token found. Please add a token in settings.'); + console.warn('[DevWatch] No GitHub connection found. Please connect GitHub in settings.'); return; } @@ -286,7 +286,7 @@ async function fetchRepoActivity(repo, token, since, filters) { // Store error for user display but don't crash let userMessage = 'Unable to fetch repository activity'; if (error.message.includes('401')) { - userMessage = 'Authentication failed. Please check your GitHub token.'; + userMessage = 'GitHub sign-in expired or was revoked. Reconnect GitHub in settings.'; } else if (error.message.includes('403')) { userMessage = 'Access denied or rate limit exceeded.'; } else if (error.message.includes('404')) { diff --git a/options/controllers/export-import-controller.js b/options/controllers/export-import-controller.js index 5740b07..f28c245 100644 --- a/options/controllers/export-import-controller.js +++ b/options/controllers/export-import-controller.js @@ -53,7 +53,7 @@ export async function handleImportFile(event, loadSettingsCallback) { } const confirmed = confirm( - 'This will replace your current settings (except GitHub token). Continue?' + 'This will replace your current settings (except your GitHub connection). Continue?' ); if (!confirmed) { diff --git a/options/controllers/import-controller.js b/options/controllers/import-controller.js index f72d083..b7b9b99 100644 --- a/options/controllers/import-controller.js +++ b/options/controllers/import-controller.js @@ -162,7 +162,7 @@ async function fetchReposFromGitHub(type, token) { if (!response.ok) { if (response.status === 401) { - throw new Error('Invalid GitHub token'); + throw new Error('GitHub sign-in expired or was revoked'); } else if (response.status === 403) { throw new Error('Rate limit exceeded or insufficient permissions'); } else { diff --git a/options/options.html b/options/options.html index b44c6be..85793cb 100644 --- a/options/options.html +++ b/options/options.html @@ -659,7 +659,7 @@

Import/Export Settings

- Your GitHub token is never exported for security + Your GitHub connection is never exported for security

diff --git a/options/options.js b/options/options.js index 1e7b900..9052878 100644 --- a/options/options.js +++ b/options/options.js @@ -815,7 +815,7 @@ async function validateRepo(repo) { const githubToken = await getAccessToken(); if (!githubToken) { - return { valid: false, error: 'No GitHub token found. Please add a token first.' }; + return { valid: false, error: 'No GitHub connection found. Connect GitHub first.' }; } // First do basic validation @@ -1044,7 +1044,7 @@ async function clearAllData() { async function resetSettings() { // Show confirmation dialog const confirmed = confirm( - 'This will reset ALL settings to defaults and clear your GitHub token and repositories.\n\nThis action cannot be undone. Are you sure?' + 'This will reset ALL settings to defaults and clear your GitHub connection and repositories.\n\nThis action cannot be undone. Are you sure?' ); if (!confirmed) { diff --git a/shared/config.js b/shared/config.js index fefb200..5049f6a 100644 --- a/shared/config.js +++ b/shared/config.js @@ -87,12 +87,12 @@ export const FEATURES = { // Error Messages export const ERROR_MESSAGES = { NETWORK_ERROR: 'Network connection error. Please check your internet connection.', - AUTH_FAILED: 'Authentication failed. Please check your GitHub token.', + AUTH_FAILED: 'GitHub sign-in expired or was revoked. Reconnect GitHub and try again.', RATE_LIMITED: 'Rate limit exceeded. Please wait before making more requests.', NOT_FOUND: 'Repository not found or access denied.', FORBIDDEN: 'Access denied. Please check your permissions.', SERVER_ERROR: 'GitHub API is experiencing issues. Please try again later.', - INVALID_TOKEN: 'Invalid GitHub token format.', + INVALID_TOKEN: 'GitHub sign-in is no longer valid. Reconnect GitHub and try again.', CORS_ERROR: 'CORS error. Please check your browser settings.', STORAGE_ERROR: 'Storage error. Please check your browser settings.', VALIDATION_ERROR: 'Invalid input. Please check your repository format.', diff --git a/shared/error-handler.js b/shared/error-handler.js index 319e154..77d247f 100644 --- a/shared/error-handler.js +++ b/shared/error-handler.js @@ -29,9 +29,9 @@ const ERROR_MESSAGES = { action: 'Try Again' }, [ERROR_TYPES.AUTHENTICATION]: { - title: 'Token Expired', - message: 'Your GitHub token has expired or is invalid. Tokens expire after 90 days by default. Please create a new token and update it in settings.', - action: 'Go to Settings' + title: 'GitHub Sign-In Needed', + message: 'Your GitHub sign-in expired or was revoked. Reconnect GitHub in settings to keep monitoring repositories.', + action: 'Open Settings' }, [ERROR_TYPES.RATE_LIMIT]: { title: 'Rate Limit Exceeded', @@ -96,7 +96,14 @@ export function classifyError(error, response = null) { if (errorMessage.includes('fetch') || errorMessage.includes('network')) { return ERROR_TYPES.NETWORK; } - if (errorMessage.includes('token') || errorMessage.includes('auth')) { + if ( + errorMessage.includes('token') + || errorMessage.includes('auth') + || errorMessage.includes('sign-in') + || errorMessage.includes('revoked') + || errorMessage.includes('unauthorized') + || errorMessage.includes('expired') + ) { return ERROR_TYPES.AUTHENTICATION; } if (errorMessage.includes('rate limit') || errorMessage.includes('429')) { @@ -353,4 +360,4 @@ export async function handleApiResponse(response, context = 'API request') { } return response; -} \ No newline at end of file +} diff --git a/shared/github-api.js b/shared/github-api.js index dfc561d..ef7829f 100644 --- a/shared/github-api.js +++ b/shared/github-api.js @@ -24,7 +24,7 @@ export function handleApiResponse(response, repo = '') { if (!response.ok) { let error; if (response.status === 401) { - error = new Error('Invalid GitHub token'); + error = new Error('GitHub sign-in expired or was revoked'); } else if (response.status === 403) { error = new Error('Rate limit exceeded'); } else if (response.status === 404) { diff --git a/shared/onboarding.js b/shared/onboarding.js index 4529f2f..adb6b8a 100644 --- a/shared/onboarding.js +++ b/shared/onboarding.js @@ -125,20 +125,11 @@ export class OnboardingManager { async getPopularRepos() { try { - // Try to fetch trending repositories from GitHub API - // Get stored token (if the user entered one) and include it to - // improve rate limits and ensure access to any private data the - // token allows. We intentionally avoid setting the User-Agent - // header in browser fetch to avoid disallowed header errors. - // Prefer token stored in onboarding step data (user-entered token) - // so that prefetching works even if storage hasn't been updated yet - // by UI code that persists the token. Fallback to getToken() to - // support tokens set outside onboarding. - const tokenStep = await this.getStepData('token'); + // Try to fetch trending repositories from GitHub API using the + // current authenticated session when it is available. const storedToken = await getAccessToken(); - const githubToken = tokenStep?.token || storedToken; - const headers = githubToken - ? createHeaders(githubToken) + const headers = storedToken + ? createHeaders(storedToken) : { 'Accept': 'application/vnd.github.v3+json' }; const apiUrl = 'https://api.github.com/search/repositories?q=stars:1000..50000&sort=stars&order=desc&per_page=20'; diff --git a/shared/repository-validator.js b/shared/repository-validator.js index 689cacb..9eed201 100644 --- a/shared/repository-validator.js +++ b/shared/repository-validator.js @@ -24,7 +24,7 @@ export function isValidRepoFormat(repo) { * 3. Return basic metadata needed for display * * @param {string} repo - Repository identifier in format "owner/repo" - * @param {string} [token] - Optional GitHub token for authentication + * @param {string} [token] - Optional GitHub access token for authentication * @returns {Promise} Validation result * @property {boolean} valid - Whether repository is valid * @property {Object|null} metadata - Basic repository metadata if valid @@ -89,7 +89,7 @@ export async function validateRepository(repo, token = null) { case 401: return { valid: false, - error: 'Authentication failed. Check your GitHub token.' + error: 'GitHub sign-in expired or was revoked. Reconnect GitHub and try again.' }; default: return { @@ -117,7 +117,7 @@ export async function validateRepository(repo, token = null) { * This can be used when more detailed information is needed * * @param {string} repo - Repository identifier in format "owner/repo" - * @param {string} [token] - Optional GitHub token for authentication + * @param {string} [token] - Optional GitHub access token for authentication * @returns {Promise} Enhanced validation result with additional metadata */ export async function validateRepositoryEnhanced(repo, token = null) { @@ -174,7 +174,7 @@ export async function validateRepositoryEnhanced(repo, token = null) { /** * Batch validate multiple repositories * @param {Array} repos - Array of repository strings - * @param {string} [token] - Optional GitHub token for authentication + * @param {string} [token] - Optional GitHub access token for authentication * @param {Function} [progressCallback] - Optional progress callback * @returns {Promise} Array of validation results */ @@ -230,4 +230,4 @@ export function quickValidateRepo(repo) { valid: true, normalized: trimmedRepo }; -} \ No newline at end of file +} diff --git a/shared/storage-helpers.js b/shared/storage-helpers.js index dd89f4b..ce5eb81 100644 --- a/shared/storage-helpers.js +++ b/shared/storage-helpers.js @@ -253,7 +253,7 @@ export async function getAccessToken() { return authSession.accessToken; } - return getToken(); + return null; } /** diff --git a/tests/background.test.js b/tests/background.test.js index d8410a0..c6b5532 100644 --- a/tests/background.test.js +++ b/tests/background.test.js @@ -956,10 +956,10 @@ describe('Background Service Worker', () => { chrome.storage.session.get.mockImplementation((keys, callback) => { const result = {}; - if (Array.isArray(keys) && keys.includes('githubToken')) { - result.githubToken = mockToken; - } else if (keys === 'githubToken') { - result.githubToken = mockToken; + if (Array.isArray(keys) && keys.includes('githubAuthSession')) { + result.githubAuthSession = { accessToken: mockToken }; + } else if (keys === 'githubAuthSession') { + result.githubAuthSession = { accessToken: mockToken }; } callback(result); }); @@ -968,12 +968,12 @@ describe('Background Service Worker', () => { const result = {}; if (Array.isArray(keys)) { keys.forEach(key => { - if (key === 'githubToken') result[key] = mockToken; + if (key === 'encryptedGithubAuthSession') result[key] = null; else if (key === 'activities') result[key] = []; else if (key === 'rateLimit') result[key] = null; }); - } else if (keys === 'githubToken') { - result.githubToken = mockToken; + } else if (keys === 'encryptedGithubAuthSession') { + result.encryptedGithubAuthSession = null; } callback(result); }); @@ -993,10 +993,10 @@ describe('Background Service Worker', () => { allowUnexpectedConsole('warn'); chrome.storage.session.get.mockImplementation((keys, callback) => { const result = {}; - if (typeof keys === 'string' && keys === 'githubToken') { - result.githubToken = null; - } else if (Array.isArray(keys) && keys.includes('githubToken')) { - result.githubToken = null; + if (typeof keys === 'string' && keys === 'githubAuthSession') { + result.githubAuthSession = null; + } else if (Array.isArray(keys) && keys.includes('githubAuthSession')) { + result.githubAuthSession = null; } callback(result); }); diff --git a/tests/error-handler.test.js b/tests/error-handler.test.js index e735578..72cdab2 100644 --- a/tests/error-handler.test.js +++ b/tests/error-handler.test.js @@ -72,9 +72,9 @@ describe('Error Handler', () => { const error = getUserFriendlyError(new Error('Invalid token')); expect(error.type).toBe('auth'); - expect(error.title).toBe('Token Expired'); - expect(error.message).toContain('expired or is invalid'); - expect(error.action).toBe('Go to Settings'); + expect(error.title).toBe('GitHub Sign-In Needed'); + expect(error.message).toContain('expired or was revoked'); + expect(error.action).toBe('Open Settings'); }); it('should return user-friendly network error', () => { diff --git a/tests/github-api.test.js b/tests/github-api.test.js index 472a291..1a2ed81 100644 --- a/tests/github-api.test.js +++ b/tests/github-api.test.js @@ -56,7 +56,7 @@ describe('GitHub API Helpers', () => { statusText: 'Unauthorized' }; - expect(() => handleApiResponse(mockResponse, 'owner/repo')).toThrow('Invalid GitHub token'); + expect(() => handleApiResponse(mockResponse, 'owner/repo')).toThrow('GitHub sign-in expired or was revoked'); }); it('should throw error for 403 rate limit', () => { diff --git a/tests/import-controller.test.js b/tests/import-controller.test.js index 1ffd163..d1c47a6 100644 --- a/tests/import-controller.test.js +++ b/tests/import-controller.test.js @@ -242,7 +242,7 @@ describe('import-controller', () => { await openImportModal('starred', []); expect(errorState.style.display).toBe('block'); - expect(errorMessage.textContent).toContain('Invalid GitHub token'); + expect(errorMessage.textContent).toContain('GitHub sign-in expired or was revoked'); }); test('handles API error with 403 status', async () => { diff --git a/tests/onboarding.test.js b/tests/onboarding.test.js index caf05c2..1e3956a 100644 --- a/tests/onboarding.test.js +++ b/tests/onboarding.test.js @@ -167,7 +167,7 @@ describe('Onboarding - token persistence', () => { expect(document.getElementById('tokenStatus').textContent).toBe(''); }); - test('getPopularRepos uses stored token in request', async () => { + test('getPopularRepos uses stored auth session in request', async () => { const mockFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, @@ -179,30 +179,30 @@ describe('Onboarding - token persistence', () => { global.fetch = mockFetch; - // Set a token in local storage - _localStorage.githubToken = 'ghp_TEST_TOKEN'; + // Set an auth session in storage + _localStorage.githubAuthSession = { + accessToken: 'gho_TEST_TOKEN', + authType: 'oauth_device' + }; const manager = new OnboardingManager(); - // Clear any step token so that the function picks up chrome.storage.local token + // Clear any onboarding step state so the function uses the active auth session await manager.saveStepData('token', {}); const result = await manager.getPopularRepos(); // Ensure fetch was called with Authorization header expect(mockFetch).toHaveBeenCalled(); const [, options] = mockFetch.mock.calls[0]; - expect(options.headers['Authorization']).toBe('Bearer ghp_TEST_TOKEN'); + expect(options.headers['Authorization']).toBe('Bearer gho_TEST_TOKEN'); // Should return at least 1 repo from our mocked response expect(result.length).toBeGreaterThan(0); expect(result[0].name).toBe('repo'); }); - test('getPopularRepos uses onboarding step token when storage missing', async () => { + test('getPopularRepos falls back to unauthenticated headers when no auth session exists', async () => { const manager = new OnboardingManager(); - // Save token inside onboarding step data, not in chrome.local - await manager.saveStepData('token', { token: 'ghp_STEP_TOKEN' }); - const mockFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, @@ -218,7 +218,8 @@ describe('Onboarding - token persistence', () => { expect(mockFetch).toHaveBeenCalled(); const [, options] = mockFetch.mock.calls[0]; - expect(options.headers['Authorization']).toBe('Bearer ghp_STEP_TOKEN'); + expect(options.headers['Authorization']).toBeUndefined(); + expect(options.headers['Accept']).toBe('application/vnd.github.v3+json'); expect(result.length).toBeGreaterThan(0); }); diff --git a/tests/options.test.js b/tests/options.test.js index 9c327d2..d286728 100644 --- a/tests/options.test.js +++ b/tests/options.test.js @@ -6,7 +6,7 @@ global.chrome = { sync: { get: jest.fn((keys, callback) => { // Always call callback if provided - if (callback) callback({ githubToken: null }); + if (callback) callback({}); }), set: jest.fn((items, callback) => { // Always call callback if provided @@ -180,12 +180,11 @@ describe('Options Page - Repository Management', () => { describe('validateRepo', () => { beforeEach(() => { - // Mock token storage for validateRepo tests - // Mock session storage to return token (getToken checks session first) + // Mock auth session storage for validateRepo tests chrome.storage.session.get.mockImplementation((keys, callback) => { const result = {}; - if (keys.includes('githubToken')) { - result.githubToken = 'test-token'; + if (Array.isArray(keys) && keys.includes('githubAuthSession')) { + result.githubAuthSession = { accessToken: 'test-token' }; } if (callback) callback(result); return Promise.resolve(result); @@ -299,7 +298,7 @@ describe('Options Page - Repository Management', () => { const result = await validateRepo('some/repo'); expect(result.valid).toBe(false); - expect(result.error).toContain('Authentication failed'); + expect(result.error).toContain('GitHub sign-in expired or was revoked'); }); test('provides default values for missing metadata', async () => { diff --git a/tests/phase1.test.js b/tests/phase1.test.js index 2d7fbb9..a3f477c 100644 --- a/tests/phase1.test.js +++ b/tests/phase1.test.js @@ -168,7 +168,7 @@ describe('Error Display', () => { test('shows recent errors', () => { allowUnexpectedConsole('error'); const error = { - message: 'Invalid GitHub token', + message: 'GitHub sign-in expired or was revoked', repo: 'facebook/react', timestamp: Date.now() }; @@ -177,8 +177,8 @@ describe('Error Display', () => { const errorMsg = document.getElementById('errorMessage'); expect(errorMsg.style.display).toBe('block'); - expect(errorMsg.textContent).toContain('Token Expired'); - expect(errorMsg.textContent).toContain('invalid'); + expect(errorMsg.textContent).toContain('GitHub Sign-In Needed'); + expect(errorMsg.textContent).toContain('revoked'); }); test('displays all errors when shown', () => { diff --git a/tests/repository-validator.test.js b/tests/repository-validator.test.js index 4141e44..2eda289 100644 --- a/tests/repository-validator.test.js +++ b/tests/repository-validator.test.js @@ -139,7 +139,7 @@ describe('repository-validator', () => { const result = await validateRepository('test/repo'); expect(result.valid).toBe(false); - expect(result.error).toContain('Authentication failed'); + expect(result.error).toContain('GitHub sign-in expired or was revoked'); }); test('handles unknown API errors', async () => { From f454bf6cbe79296e7cc398e54a33d99ec0a16e4d Mon Sep 17 00:00:00 2001 From: jonmartin721 Date: Wed, 11 Mar 2026 17:04:12 -0500 Subject: [PATCH 05/11] refresh tests and docs for oauth flow --- PRIVACY.md | 19 +++---- README.md | 10 ++-- SECURITY.md | 8 +-- options/controllers/token-controller.js | 3 -- options/options.css | 11 ++++ options/options.html | 3 +- popup/views/onboarding-view.js | 3 +- shared/config.js | 1 - shared/crypto-utils.js | 2 +- shared/onboarding.js | 4 -- shared/storage-helpers.js | 68 ------------------------- tests/import-controller.test.js | 5 +- tests/options-main.test.js | 2 +- tests/options-token-controller.test.js | 5 -- tests/storage-helpers.test.js | 62 ++++------------------ 15 files changed, 48 insertions(+), 158 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index b879d80..021aedb 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -12,9 +12,10 @@ GitHub Devwatch is a Chrome extension for monitoring activity on GitHub reposito GitHub Devwatch collects and stores the following data **locally on your device only**: -1. **GitHub Personal Access Token** +1. **GitHub OAuth Session** + - Created when you connect GitHub through the built-in device-flow sign-in - Stored by the extension in Chrome storage - - Current builds encrypt the token before writing it to local storage and keep a decrypted session copy while the extension is running + - Current builds encrypt the auth session before writing it to local storage and keep a decrypted session copy while the extension is running - Used only to authenticate with GitHub's API - Not sent to third-party services operated by this project - Never shared with anyone @@ -46,16 +47,16 @@ GitHub Devwatch collects and stores the following data **locally on your device All data collected is used exclusively to provide the extension's functionality: -- Your GitHub token authenticates API requests to GitHub +- Your GitHub connection authenticates API requests to GitHub - Your repository list determines which repositories to monitor - Your settings customize how the extension behaves - Activity data is displayed in the extension popup for your review ## Data Storage -- The extension uses Chrome storage APIs for settings, cached activity, and token handling +- The extension uses Chrome storage APIs for settings, cached activity, and GitHub sign-in handling - Settings and repository lists can optionally sync across your Chrome browsers if you use Chrome Sync -- Token handling uses local and session storage rather than Chrome sync +- GitHub sign-in data uses local and session storage rather than Chrome sync - You can clear all data at any time by uninstalling the extension or using Chrome's "Clear extension data" feature ## Third-Party Services @@ -65,7 +66,7 @@ All data collected is used exclusively to provide the extension's functionality: This extension communicates with GitHub's API (api.github.com) to fetch repository activity. When you use this extension: - API requests are made directly from your browser to GitHub -- Requests include your GitHub Personal Access Token for authentication +- Requests include your GitHub OAuth access token for authentication - GitHub's privacy policy and terms of service apply to these interactions - See GitHub's privacy policy at: https://docs.github.com/en/site-policy/privacy-policies/github-privacy-statement @@ -90,7 +91,7 @@ GitHub Devwatch does **NOT**: The extension requests the following Chrome permissions: -- **storage**: To save your settings, token, and activity data locally +- **storage**: To save your settings, GitHub sign-in state, and activity data locally - **alarms**: To periodically check for new repository activity - **notifications**: To show you browser notifications for new activity - **Host permission for api.github.com**: To fetch repository activity from GitHub's API @@ -104,14 +105,14 @@ You have complete control over your data: - **View Your Data**: All settings are visible in the extension's options page - **Delete Your Data**: Uninstall the extension to remove all data, or use the "Clear All Data" option in settings - **Export Your Data**: Use the backup/restore feature to export your settings -- **Revoke Access**: Remove or regenerate your GitHub Personal Access Token at any time via GitHub's settings +- **Revoke Access**: Disconnect locally in DevWatch, and revoke the OAuth app in GitHub's authorized applications settings at any time ## Security Current builds include several concrete safeguards: - All API requests use HTTPS -- The token is encrypted before it is persisted locally +- The GitHub auth session is encrypted before it is persisted locally - The codebase includes input sanitization and GitHub URL validation checks - Extension pages use a Content Security Policy diff --git a/README.md b/README.md index b8daeb1..121767e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Monitor pull requests, issues, and releases across multiple GitHub repositories ## Key Features -- **Guided Setup** - Built-in setup flow for token creation and repository selection +- **Guided Setup** - Built-in GitHub sign-in flow and repository selection - **Browser Notifications** - Get notified about new PRs, issues, and releases - **Multi-Repo Monitoring** - Watch up to 50 repositories from one interface - **Configurable Updates** - Check every 5, 15, 30, or 60 minutes @@ -30,7 +30,7 @@ Monitor pull requests, issues, and releases across multiple GitHub repositories 3. Grant permissions when prompted 4. Follow the guided setup wizard on first launch -**GitHub Token Permissions**: You'll need a [Personal Access Token](https://github.com/settings/tokens/new) with `repo` (for private repos) or `public_repo` (for public only). +**GitHub Sign-In Permissions**: DevWatch uses GitHub OAuth device flow and requests `repo` plus `read:user` so it can monitor private repositories and show the connected account in the UI. ### Manual Installation (For Development) @@ -53,7 +53,7 @@ cd devwatch-github ### First-Time Setup The built-in setup flow walks you through: -1. Create a GitHub token +1. Connect your GitHub account 2. Add repositories to watch 3. Choose activity types (PRs, Issues, Releases) @@ -74,7 +74,7 @@ The built-in setup flow walks you through: Filter by type (All/PRs/Issues/Releases), search activities, refresh manually, or browse the archive. Click any item to open in GitHub. ### Settings Page -Manage your GitHub token, watched repositories, activity filters, check interval, notifications, and theme. Export/import settings for backup. +Manage your GitHub connection, watched repositories, activity filters, check interval, notifications, and theme. Export/import settings for backup.
Settings page for configuring repositories @@ -101,7 +101,7 @@ That said, this project has not gone through a formal accessibility audit or doc ## Privacy & Security Notes -The extension talks directly to GitHub's API and does not use a separate analytics or sync backend. It stores settings and cached activity in Chrome extension storage, and the current build encrypts the GitHub token before persisting it locally while keeping a decrypted session copy available at runtime. +The extension talks directly to GitHub's API and does not use a separate analytics or sync backend. It stores settings and cached activity in Chrome extension storage, and the current build encrypts the GitHub auth session before persisting it locally while keeping a decrypted session copy available at runtime. - **Direct network access** - Requests go to `api.github.com`, plus `registry.npmjs.org` only when you use package-name lookup - **Scoped browser permissions** - The manifest asks for `storage`, `alarms`, and `notifications` diff --git a/SECURITY.md b/SECURITY.md index 044cb0e..a8193f9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,7 +15,7 @@ I'll respond within 48 hours and work with you to understand and address the iss Things I want to know about: - XSS vulnerabilities or ways to inject malicious code -- Token leakage or insecure storage +- OAuth session leakage or insecure storage - Ways to access other users' data - Privilege escalation - Dependencies with known CVEs @@ -32,8 +32,8 @@ These are better suited for regular issues: The extension includes several concrete protections, but this project has not been through a formal external security audit. -### Token Storage -- GitHub tokens are encrypted before they are written to local extension storage +### GitHub Sign-In Storage +- GitHub auth sessions are encrypted before they are written to local extension storage - A decrypted copy may be cached in session storage while the extension is running - Never transmitted to third-party servers @@ -49,7 +49,7 @@ The extension includes several concrete protections, but this project has not be ### API Security - All requests use HTTPS -- Tokens are included in headers, never in URLs +- OAuth access tokens are included in headers, never in URLs - Rate limiting is respected to prevent abuse ## Supported Versions diff --git a/options/controllers/token-controller.js b/options/controllers/token-controller.js index 2389cae..4cc0c79 100644 --- a/options/controllers/token-controller.js +++ b/options/controllers/token-controller.js @@ -1,7 +1,6 @@ import { completeGitHubDeviceAuth } from '../../shared/auth.js'; import { clearAuthSession, - clearToken as clearLegacyToken, getAuthSession, setAuthSession } from '../../shared/storage-helpers.js'; @@ -91,7 +90,6 @@ export async function clearToken() { } await clearAuthSession(); - await clearLegacyToken(); applyStoredConnection(null); notifications.info('GitHub disconnected'); @@ -133,7 +131,6 @@ export async function connectGitHub(_toastManager) { }); await setAuthSession(result.authSession); - await clearLegacyToken(); applyStoredConnection(result.authSession); nextButtonLabel = 'Reconnect GitHub'; diff --git a/options/options.css b/options/options.css index f1d0ff8..e5b35a6 100644 --- a/options/options.css +++ b/options/options.css @@ -2203,6 +2203,17 @@ body.dark-mode .notification-toggle input:checked + .toggle-slider { flex-wrap: wrap; } +#deviceCodeSection { + flex-direction: column; + align-items: stretch; +} + +.device-code-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + #deviceCodeSection input { letter-spacing: 0.12em; font-weight: 600; diff --git a/options/options.html b/options/options.html index 85793cb..e6471d8 100644 --- a/options/options.html +++ b/options/options.html @@ -78,6 +78,7 @@

Connect GitHub

@@ -613,7 +614,7 @@

Clear All Data

Reset to Defaults

-

Reset all settings to default values. Your token and repositories will be cleared.

+

Reset all settings to default values. Your GitHub connection and repositories will be cleared.

@@ -612,17 +646,45 @@ export async function setupOnboardingStepListeners(currentStep, loadActivitiesCa function setupTokenStepListeners() { const tokenInput = document.getElementById('tokenInput'); + const copyTokenCodeBtn = document.getElementById('copyTokenCodeBtn'); const validateBtn = document.getElementById('validateTokenBtn'); const tokenStatus = document.getElementById('tokenStatus'); const nextBtn = document.getElementById('nextBtn'); const tokenElements = { tokenInput, validateBtn, tokenStatus, nextBtn }; + tokenInput?.addEventListener('click', () => { + tokenInput.select(); + }); + + tokenInput?.addEventListener('focus', () => { + tokenInput.select(); + }); + + copyTokenCodeBtn?.addEventListener('click', async () => { + const userCode = tokenInput?.value?.trim(); + if (!userCode) { + return; + } + + try { + const copied = await copyTextToClipboard(userCode); + if (copied) { + tokenStatus.innerHTML = getStatusMarkup('success', `Copied ${userCode}. Paste it into GitHub to finish connecting.`); + } + } catch (_error) { + tokenStatus.innerHTML = getStatusMarkup('error', 'Could not copy the code automatically. Select it manually.'); + } + }); + // Resume the device flow if the popup was closed while GitHub was waiting // for approval in another tab. void (async () => { const existingTokenData = await onboardingManager.getStepData('token'); if (!existingTokenData?.validated && existingTokenData?.pendingDeviceAuth) { tokenInput.value = existingTokenData.userCode || existingTokenData.pendingDeviceAuth.userCode || ''; + if (copyTokenCodeBtn) { + copyTokenCodeBtn.disabled = !tokenInput.value; + } try { await completePendingDeviceAuth(existingTokenData, tokenElements, { @@ -653,6 +715,9 @@ function setupTokenStepListeners() { const pendingDeviceAuth = createPendingDeviceAuthState(deviceCodeData); tokenInput.value = deviceCodeData.userCode || ''; + if (copyTokenCodeBtn) { + copyTokenCodeBtn.disabled = !tokenInput.value; + } tokenStatus.innerHTML = getStatusMarkup('loading', `Enter ${deviceCodeData.userCode} on GitHub to finish connecting.`); await onboardingManager.saveStepData('token', { userCode: deviceCodeData.userCode, diff --git a/tests/onboarding.test.js b/tests/onboarding.test.js index 2220998..0e425aa 100644 --- a/tests/onboarding.test.js +++ b/tests/onboarding.test.js @@ -132,6 +132,12 @@ describe('Onboarding - token persistence', () => { configurable: true, value: TextEncoder }); + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { + writeText: jest.fn(async () => undefined) + } + }); // Reload modules to reset module-level onboardingManager cache jest.resetModules(); const module = await import('../popup/views/onboarding-view.js'); @@ -306,6 +312,24 @@ describe('Onboarding - token persistence', () => { expect(onboardingHtml).toContain('Connected'); }); + test('connect step lets you copy the saved device code', async () => { + await renderTokenStep({ + data: { + token: { + userCode: 'ABCD-EFGH', + validated: false + } + } + }); + + document.getElementById('copyTokenCodeBtn').click(); + await Promise.resolve(); + await Promise.resolve(); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('ABCD-EFGH'); + expect(document.getElementById('tokenStatus').textContent).toContain('Copied ABCD-EFGH'); + }); + test('connect step shows device instructions when sign-in starts', async () => { global.fetch = jest.fn().mockResolvedValueOnce({ ok: true, From a5994e8feacba2a33201a6bac1fa4eecc28b64b6 Mon Sep 17 00:00:00 2001 From: jonmartin721 Date: Thu, 12 Mar 2026 23:27:35 -0500 Subject: [PATCH 08/11] move watched repos to local storage Chrome sync storage has an 8KB per-item limit which we were hitting with larger repo lists. This moves watchedRepos to local storage (unlimited quota) and adds a one-time migration for existing installs. Also added import validation to enforce the 50 repo limit (unless unlimited mode is enabled) and better error handling around the import flow. --- background.js | 6 +- .../controllers/export-import-controller.js | 6 +- options/controllers/import-controller.js | 66 ++++++++++++++++--- options/options.js | 31 +++++---- popup/popup.js | 5 +- popup/views/onboarding-view.js | 12 ++-- shared/state-manager.js | 13 ++-- shared/storage-helpers.js | 50 +++++++++++++- tests/export-import-controller.test.js | 33 ++++++---- tests/import-controller.test.js | 49 +++++++++++--- 10 files changed, 211 insertions(+), 60 deletions(-) diff --git a/background.js b/background.js index 88d9e24..24dc655 100644 --- a/background.js +++ b/background.js @@ -1,5 +1,5 @@ import { createHeaders, handleApiResponse, mapActivity, filterActivitiesByDate } from './shared/github-api.js'; -import { getSyncItems, getLocalItems, setLocalItem, getExcludedRepos, getAccessToken, getFilteringSettings } from './shared/storage-helpers.js'; +import { getSyncItems, getLocalItems, setLocalItem, getExcludedRepos, getAccessToken, getFilteringSettings, getWatchedRepos } from './shared/storage-helpers.js'; import { extractRepoName } from './shared/repository-utils.js'; import { safelyOpenUrl } from './shared/security.js'; @@ -75,8 +75,8 @@ async function checkGitHubActivity() { try { const githubToken = await getAccessToken(); - const { watchedRepos, lastCheck, filters, notifications, mutedRepos, snoozedRepos, unmutedRepos } = await getSyncItems([ - 'watchedRepos', + const watchedRepos = await getWatchedRepos(); + const { lastCheck, filters, notifications, mutedRepos, snoozedRepos, unmutedRepos } = await getSyncItems([ 'lastCheck', 'filters', 'notifications', diff --git a/options/controllers/export-import-controller.js b/options/controllers/export-import-controller.js index f28c245..1267cad 100644 --- a/options/controllers/export-import-controller.js +++ b/options/controllers/export-import-controller.js @@ -1,16 +1,18 @@ import { NotificationManager } from '../../shared/ui/notification-manager.js'; +import { getWatchedRepos, setWatchedRepos } from '../../shared/storage-helpers.js'; const notifications = NotificationManager.getInstance(); export async function exportSettings() { try { const syncData = await chrome.storage.sync.get(null); + const watchedRepos = await getWatchedRepos(); const exportData = { version: '1.0.0', exportedAt: new Date().toISOString(), settings: { - watchedRepos: syncData.watchedRepos || [], + watchedRepos, mutedRepos: syncData.mutedRepos || [], pinnedRepos: syncData.pinnedRepos || [], filters: syncData.filters || { prs: true, issues: true, releases: true }, @@ -63,8 +65,8 @@ export async function handleImportFile(event, loadSettingsCallback) { } const settings = importData.settings; + await setWatchedRepos(settings.watchedRepos || []); await chrome.storage.sync.set({ - watchedRepos: settings.watchedRepos || [], mutedRepos: settings.mutedRepos || [], pinnedRepos: settings.pinnedRepos || [], filters: settings.filters || { prs: true, issues: true, releases: true }, diff --git a/options/controllers/import-controller.js b/options/controllers/import-controller.js index b7b9b99..4f2dd3f 100644 --- a/options/controllers/import-controller.js +++ b/options/controllers/import-controller.js @@ -1,4 +1,5 @@ -import { getAccessToken } from '../../shared/storage-helpers.js'; +import { STORAGE_CONFIG } from '../../shared/config.js'; +import { getAccessToken, getSyncItem, setWatchedRepos } from '../../shared/storage-helpers.js'; import { createHeaders } from '../../shared/github-api.js'; import { escapeHtml, unescapeHtml } from '../../shared/sanitize.js'; import { formatDateVerbose } from '../../shared/utils.js'; @@ -61,6 +62,25 @@ function formatNumber(num) { return num.toString(); } +function getRepoFullName(repo) { + if (typeof repo === 'string') { + return repo; + } + + return repo?.fullName || ''; +} + +function normalizeImportedRepo(repo) { + return { + fullName: repo.fullName, + description: repo.description || 'No description provided', + language: repo.language || 'Unknown', + stars: repo.stars || 0, + updatedAt: repo.updatedAt || new Date().toISOString(), + addedAt: new Date().toISOString() + }; +} + export async function openImportModal(type, watchedRepos) { const token = await getAccessToken(); if (!token) { @@ -99,7 +119,9 @@ export async function openImportModal(type, watchedRepos) { const repos = await fetchReposFromGitHub(type, token); const alreadyAdded = new Set( - (watchedRepos || []).map(r => r.fullName.toLowerCase()) + (watchedRepos || []) + .map(repo => getRepoFullName(repo).toLowerCase()) + .filter(Boolean) ); importModalState.repos = repos.map(repo => ({ @@ -207,6 +229,12 @@ export function closeImportModal() { const modal = document.getElementById('importModal'); modal.classList.remove('show'); document.getElementById('importRepoSearch').value = ''; + const selectAllCheckbox = document.getElementById('selectAllImport'); + if (selectAllCheckbox) { + selectAllCheckbox.checked = false; + } + document.getElementById('selectedCount').textContent = '0'; + document.getElementById('confirmImportBtn').disabled = true; modal.removeEventListener('keydown', handleModalFocusTrap); @@ -311,14 +339,36 @@ export async function importSelectedRepos(watchedRepos, onReposAdded) { return; } - const reposWithTimestamp = reposToImport.map(repo => ({ - ...repo, - addedAt: new Date().toISOString() - })); + const existingRepoNames = new Set( + (watchedRepos || []) + .map(repo => getRepoFullName(repo).toLowerCase()) + .filter(Boolean) + ); + const uniqueReposToImport = reposToImport.filter(repo => !existingRepoNames.has(repo.fullName.toLowerCase())); + + if (uniqueReposToImport.length === 0) { + return; + } + + const allowUnlimitedRepos = await getSyncItem('allowUnlimitedRepos', false); + + if (!allowUnlimitedRepos) { + const remainingSlots = Math.max(STORAGE_CONFIG.MAX_WATCHED_REPOS - existingRepoNames.size, 0); + + if (uniqueReposToImport.length > remainingSlots) { + throw new Error( + `Import would exceed the ${STORAGE_CONFIG.MAX_WATCHED_REPOS} repository limit. Select ${remainingSlots} or fewer repositories, or enable "Unlimited Repositories" in Advanced settings.` + ); + } + } - watchedRepos.push(...reposWithTimestamp); + const nextWatchedRepos = [ + ...watchedRepos, + ...uniqueReposToImport.map(normalizeImportedRepo) + ]; - await chrome.storage.sync.set({ watchedRepos }); + await setWatchedRepos(nextWatchedRepos); + watchedRepos.splice(0, watchedRepos.length, ...nextWatchedRepos); if (onReposAdded) { onReposAdded(); diff --git a/options/options.js b/options/options.js index 9052878..ae85829 100644 --- a/options/options.js +++ b/options/options.js @@ -1,5 +1,5 @@ import { applyTheme, formatDateVerbose } from '../shared/utils.js'; -import { getAuthSession, getAccessToken, getLocalItems, setLocalItem } from '../shared/storage-helpers.js'; +import { getAuthSession, getAccessToken, getLocalItems, getWatchedRepos, setLocalItem, setWatchedRepos } from '../shared/storage-helpers.js'; import { createHeaders } from '../shared/github-api.js'; import { STORAGE_CONFIG, VALIDATION_PATTERNS } from '../shared/config.js'; import { validateRepository } from '../shared/repository-validator.js'; @@ -430,13 +430,20 @@ function setupEventListeners() { document.getElementById('closeImportModal').addEventListener('click', closeImportModal); document.getElementById('cancelImportBtn').addEventListener('click', closeImportModal); document.getElementById('confirmImportBtn').addEventListener('click', async () => { - await importSelectedRepos(state.watchedRepos, async () => { - // Reload repos from storage to update state - const result = await chrome.storage.sync.get(['watchedRepos']); - state.watchedRepos = result.watchedRepos || []; - renderRepoListWrapper(); - toastManager.show('Repositories imported successfully', 'success'); - }); + const confirmImportBtn = document.getElementById('confirmImportBtn'); + confirmImportBtn.disabled = true; + + try { + await importSelectedRepos(state.watchedRepos, async () => { + // Reload repos from storage to update state + state.watchedRepos = await getWatchedRepos(); + renderRepoListWrapper(); + toastManager.show('Repositories imported successfully', 'success'); + }); + } catch (error) { + toastManager.error(error.message || 'Failed to import repositories'); + updateSelectedCount(); + } }); document.getElementById('selectAllImport').addEventListener('change', (e) => { @@ -541,7 +548,6 @@ async function loadSettings() { const authSession = await getAuthSession(); const settings = await chrome.storage.sync.get([ - 'watchedRepos', 'mutedRepos', 'pinnedRepos', 'checkInterval', @@ -555,6 +561,7 @@ async function loadSettings() { ]); const snoozeSettings = await chrome.storage.sync.get(['snoozedRepos']); + const watchedRepos = await getWatchedRepos(); // Safety check: if settings is undefined or null, use defaults if (!settings || !snoozeSettings) { @@ -577,7 +584,7 @@ async function loadSettings() { syncTokenUiWithStoredCredential(false); } - state.watchedRepos = settings.watchedRepos || []; + state.watchedRepos = watchedRepos; state.mutedRepos = settings.mutedRepos || []; state.pinnedRepos = settings.pinnedRepos || []; @@ -751,7 +758,7 @@ async function addRepo() { renderRepoListWrapper(); // Auto-save - await chrome.storage.sync.set({ watchedRepos: state.watchedRepos }); + await setWatchedRepos(state.watchedRepos); // Show success toast toastManager.success(`Successfully added ${validationResult.fullName} to watched repositories`); @@ -872,7 +879,7 @@ async function removeRepo(repoFullName) { renderRepoListWrapper(); // Auto-save - await chrome.storage.sync.set({ watchedRepos: state.watchedRepos }); + await setWatchedRepos(state.watchedRepos); // Show success toast toastManager.success(`Removed ${repoFullName} from watched repositories`); diff --git a/popup/popup.js b/popup/popup.js index 6cbf8ef..e622f54 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -1,5 +1,5 @@ import { applyTheme } from '../shared/utils.js'; -import { getSyncItem } from '../shared/storage-helpers.js'; +import { getSyncItem, getWatchedRepos } from '../shared/storage-helpers.js'; import { showError } from '../shared/error-handler.js'; import { isOffline, @@ -177,8 +177,7 @@ async function handleRefresh() { // Update repo count in header async function updateRepoCount() { - const result = await chrome.storage.sync.get(['watchedRepos']); - const watchedRepos = result.watchedRepos || []; + const watchedRepos = await getWatchedRepos(); const count = watchedRepos.length; const repoCountEl = document.getElementById('repoCount'); if (repoCountEl) { diff --git a/popup/views/onboarding-view.js b/popup/views/onboarding-view.js index 943dbe4..11316e7 100644 --- a/popup/views/onboarding-view.js +++ b/popup/views/onboarding-view.js @@ -7,7 +7,7 @@ import { requestGitHubDeviceCode } from '../../shared/auth.js'; import { OnboardingManager } from '../../shared/onboarding.js'; -import { getAccessToken, setAuthSession } from '../../shared/storage-helpers.js'; +import { getAccessToken, getWatchedRepos, setAuthSession, setWatchedRepos } from '../../shared/storage-helpers.js'; import { createHeaders } from '../../shared/github-api.js'; import { escapeHtml } from '../../shared/sanitize.js'; @@ -811,8 +811,7 @@ function attachRepoButtonListeners() { const data = await response.json(); // Save repo to storage with full metadata - const result = await chrome.storage.sync.get(['watchedRepos']); - const repos = result.watchedRepos || []; + const repos = await getWatchedRepos(); // Check if repo already exists const repoExists = repos.some(r => r.fullName === repo); @@ -828,7 +827,7 @@ function attachRepoButtonListeners() { updatedAt: data.updated_at, addedAt: new Date().toISOString() }); - await chrome.storage.sync.set({ watchedRepos: repos }); + await setWatchedRepos(repos); } // Show success state @@ -917,8 +916,7 @@ function setupReposStepListeners() { if (response.ok) { const data = await response.json(); - const result = await chrome.storage.sync.get(['watchedRepos']); - const repos = result.watchedRepos || []; + const repos = await getWatchedRepos(); const repoExists = repos.some(r => r.fullName === repo); if (!repoExists) { repos.push({ @@ -931,7 +929,7 @@ function setupReposStepListeners() { updatedAt: data.updated_at, addedAt: new Date().toISOString() }); - await chrome.storage.sync.set({ watchedRepos: repos }); + await setWatchedRepos(repos); } manualInput.value = ''; repoStatus.innerHTML = getStatusMarkup('success', '✓ Repository added'); diff --git a/shared/state-manager.js b/shared/state-manager.js index a88da37..24196e2 100644 --- a/shared/state-manager.js +++ b/shared/state-manager.js @@ -3,7 +3,7 @@ * Provides consistent state handling across popup, options, and background scripts */ -import { getSyncItems, getLocalItems } from './storage-helpers.js'; +import { getSyncItems, getLocalItems, getWatchedRepos, setWatchedRepos } from './storage-helpers.js'; import { STORAGE_KEYS, STORAGE_DEFAULTS } from './storage-helpers.js'; import { STORAGE_CONFIG } from './config.js'; @@ -67,12 +67,13 @@ class StateManager { // Load settings from storage const settings = await getSyncItems(STORAGE_KEYS.SETTINGS); + const watchedRepos = await getWatchedRepos(); const activityData = await getLocalItems(STORAGE_KEYS.ACTIVITY); // Update state with loaded data this.state = { ...this.state, - watchedRepos: settings.watchedRepos || STORAGE_DEFAULTS.watchedRepos, + watchedRepos, mutedRepos: settings.mutedRepos || STORAGE_DEFAULTS.mutedRepos, snoozedRepos: settings.snoozedRepos || STORAGE_DEFAULTS.snoozedRepos, filters: { ...STORAGE_DEFAULTS.filters, ...settings.filters }, @@ -190,7 +191,11 @@ class StateManager { const persistPromises = []; // Persist settings that go to sync storage - const syncKeys = ['watchedRepos', 'mutedRepos', 'snoozedRepos', 'filters', 'notifications', 'checkInterval', 'theme', 'itemExpiryHours']; + if ('watchedRepos' in updatesObj) { + persistPromises.push(setWatchedRepos(updatesObj.watchedRepos)); + } + + const syncKeys = ['mutedRepos', 'snoozedRepos', 'filters', 'notifications', 'checkInterval', 'theme', 'itemExpiryHours']; const syncUpdates = {}; syncKeys.forEach(key => { @@ -402,4 +407,4 @@ export const markAsRead = (activityIds) => stateManager.markAsRead(activityIds); export const addWatchedRepo = (repo) => stateManager.addWatchedRepo(repo); export const removeWatchedRepo = (repo) => stateManager.removeWatchedRepo(repo); export const getFilteredActivities = () => stateManager.getFilteredActivities(); -export const getStats = () => stateManager.getStats(); \ No newline at end of file +export const getStats = () => stateManager.getStats(); diff --git a/shared/storage-helpers.js b/shared/storage-helpers.js index 1443127..ada59b5 100644 --- a/shared/storage-helpers.js +++ b/shared/storage-helpers.js @@ -5,6 +5,7 @@ import { encryptData, decryptData } from './crypto-utils.js'; const AUTH_SESSION_CACHE_KEY = 'githubAuthSession'; const AUTH_SESSION_STORAGE_KEY = 'encryptedGithubAuthSession'; +const WATCHED_REPOS_STORAGE_KEY = 'watchedRepos'; /** * Check if running in Chrome extension context @@ -148,6 +149,50 @@ export function setLocalItem(key, value) { }); } +function clearLegacySyncWatchedRepos() { + if (!isChromeExtension()) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + chrome.storage.sync.remove([WATCHED_REPOS_STORAGE_KEY], () => { + resolve(); + }); + }); +} + +/** + * Get watched repositories from local storage, with a sync-storage fallback for legacy installs. + * @returns {Promise} Watched repository records + */ +export async function getWatchedRepos() { + const localRepos = await getLocalItem(WATCHED_REPOS_STORAGE_KEY, null); + if (Array.isArray(localRepos)) { + return localRepos; + } + + const legacyRepos = await getSyncItem(WATCHED_REPOS_STORAGE_KEY, STORAGE_DEFAULTS.watchedRepos); + + if (Array.isArray(legacyRepos) && legacyRepos.length > 0) { + await setLocalItem(WATCHED_REPOS_STORAGE_KEY, legacyRepos); + await clearLegacySyncWatchedRepos(); + return legacyRepos; + } + + return STORAGE_DEFAULTS.watchedRepos; +} + +/** + * Persist watched repositories in local storage so larger repo lists do not hit sync item quotas. + * @param {Array} watchedRepos - Repository records to store + * @returns {Promise} + */ +export async function setWatchedRepos(watchedRepos = []) { + const normalizedRepos = Array.isArray(watchedRepos) ? watchedRepos : []; + await setLocalItem(WATCHED_REPOS_STORAGE_KEY, normalizedRepos); + await clearLegacySyncWatchedRepos(); +} + /** * Calculate the set of excluded repositories (muted + snoozed) * @param {Array} mutedRepos - Array of muted repository names @@ -317,10 +362,11 @@ export const STORAGE_DEFAULTS = { */ export async function getSettings() { const result = await getSyncItems(STORAGE_KEYS.SETTINGS); + const watchedRepos = await getWatchedRepos(); // Apply defaults for missing properties return { - watchedRepos: result.watchedRepos || STORAGE_DEFAULTS.watchedRepos, + watchedRepos, lastCheck: result.lastCheck || STORAGE_DEFAULTS.lastCheck, filters: { ...STORAGE_DEFAULTS.filters, ...result.filters }, notifications: { ...STORAGE_DEFAULTS.notifications, ...result.notifications }, @@ -381,7 +427,7 @@ export async function getActivityData() { * @returns {Promise} */ export async function updateSettings(updates) { - await setSyncItem('watchedRepos', updates.watchedRepos); + await setWatchedRepos(updates.watchedRepos); await setSyncItem('lastCheck', updates.lastCheck); await setSyncItem('filters', updates.filters); await setSyncItem('notifications', updates.notifications); diff --git a/tests/export-import-controller.test.js b/tests/export-import-controller.test.js index 4c3985f..d85cdfd 100644 --- a/tests/export-import-controller.test.js +++ b/tests/export-import-controller.test.js @@ -13,6 +13,12 @@ jest.unstable_mockModule('../shared/ui/notification-manager.js', () => ({ } })); +jest.unstable_mockModule('../shared/storage-helpers.js', () => ({ + getWatchedRepos: jest.fn(() => Promise.resolve([])), + setWatchedRepos: jest.fn(() => Promise.resolve()) +})); + +const { getWatchedRepos, setWatchedRepos } = await import('../shared/storage-helpers.js'); const { exportSettings, handleImportFile } = await import('../options/controllers/export-import-controller.js'); describe('export-import-controller', () => { @@ -21,6 +27,8 @@ describe('export-import-controller', () => { mockNotifications.success.mockClear(); mockNotifications.error.mockClear(); mockNotifications.info.mockClear(); + getWatchedRepos.mockClear(); + setWatchedRepos.mockClear(); // Mock chrome.storage global.chrome = { @@ -62,7 +70,6 @@ describe('export-import-controller', () => { test('creates blob with correct content structure', async () => { const mockData = { - watchedRepos: ['owner/repo1', 'owner/repo2'], mutedRepos: ['owner/muted'], pinnedRepos: ['owner/pinned'], filters: { prs: true, issues: false, releases: true }, @@ -73,6 +80,7 @@ describe('export-import-controller', () => { snoozedRepos: ['owner/snoozed'] }; + getWatchedRepos.mockResolvedValueOnce(['owner/repo1', 'owner/repo2']); chrome.storage.sync.get.mockResolvedValueOnce(mockData); await exportSettings(); @@ -89,10 +97,8 @@ describe('export-import-controller', () => { }); test('uses default values for missing settings', async () => { - chrome.storage.sync.get.mockResolvedValueOnce({ - watchedRepos: ['owner/repo1'] - // Other settings missing - }); + chrome.storage.sync.get.mockResolvedValueOnce({}); + getWatchedRepos.mockResolvedValueOnce(['owner/repo1']); await exportSettings(); @@ -179,9 +185,8 @@ describe('export-import-controller', () => { const circular = { a: 1 }; circular.self = circular; - chrome.storage.sync.get.mockResolvedValueOnce({ - watchedRepos: [circular] - }); + chrome.storage.sync.get.mockResolvedValueOnce({}); + getWatchedRepos.mockResolvedValueOnce([circular]); await exportSettings(); @@ -215,6 +220,7 @@ describe('export-import-controller', () => { expect(mockFile.text).not.toHaveBeenCalled(); expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + expect(setWatchedRepos).not.toHaveBeenCalled(); }); test('imports valid settings file', async () => { @@ -234,9 +240,9 @@ describe('export-import-controller', () => { await handleImportFile(mockEvent); + expect(setWatchedRepos).toHaveBeenCalledWith(['owner/repo1']); expect(chrome.storage.sync.set).toHaveBeenCalledWith( expect.objectContaining({ - watchedRepos: ['owner/repo1'], mutedRepos: ['owner/muted'], theme: 'dark', checkInterval: 30 @@ -274,6 +280,7 @@ describe('export-import-controller', () => { await handleImportFile(mockEvent); expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + expect(setWatchedRepos).not.toHaveBeenCalled(); expect(mockNotifications.info).toHaveBeenCalledWith('Import cancelled'); expect(mockEvent.target.value).toBe(''); }); @@ -290,6 +297,7 @@ describe('export-import-controller', () => { await handleImportFile(mockEvent); + expect(setWatchedRepos).toHaveBeenCalledWith(['owner/repo1']); const setCall = chrome.storage.sync.set.mock.calls[0][0]; expect(setCall.mutedRepos).toEqual([]); expect(setCall.pinnedRepos).toEqual([]); @@ -329,6 +337,7 @@ describe('export-import-controller', () => { await handleImportFile(mockEvent, loadSettingsCallback); + expect(setWatchedRepos).toHaveBeenCalledWith([]); expect(loadSettingsCallback).toHaveBeenCalled(); }); @@ -423,6 +432,7 @@ describe('export-import-controller', () => { await handleImportFile(mockEvent); + expect(setWatchedRepos).toHaveBeenCalledWith([]); expect(mockEvent.target.value).toBe(''); }); @@ -471,8 +481,8 @@ describe('export-import-controller', () => { await handleImportFile(mockEvent); + expect(setWatchedRepos).toHaveBeenCalledWith(['owner/repo1', 'owner/repo2']); expect(chrome.storage.sync.set).toHaveBeenCalledWith({ - watchedRepos: ['owner/repo1', 'owner/repo2'], mutedRepos: ['owner/muted1'], pinnedRepos: ['owner/pinned1'], filters: { prs: false, issues: true, releases: false }, @@ -496,6 +506,7 @@ describe('export-import-controller', () => { checkInterval: 30 }; + getWatchedRepos.mockResolvedValueOnce(originalSettings.watchedRepos); chrome.storage.sync.get.mockResolvedValueOnce(originalSettings); await exportSettings(); @@ -522,7 +533,7 @@ describe('export-import-controller', () => { // Verify the imported settings match the original const importedSettings = chrome.storage.sync.set.mock.calls[0][0]; - expect(importedSettings.watchedRepos).toEqual(originalSettings.watchedRepos); + expect(setWatchedRepos).toHaveBeenCalledWith(originalSettings.watchedRepos); expect(importedSettings.mutedRepos).toEqual(originalSettings.mutedRepos); expect(importedSettings.theme).toBe(originalSettings.theme); expect(importedSettings.checkInterval).toBe(originalSettings.checkInterval); diff --git a/tests/import-controller.test.js b/tests/import-controller.test.js index a915b45..788f931 100644 --- a/tests/import-controller.test.js +++ b/tests/import-controller.test.js @@ -2,7 +2,9 @@ import { jest, describe, test, beforeEach, expect } from '@jest/globals'; // Mock dependencies jest.unstable_mockModule('../shared/storage-helpers.js', () => ({ - getAccessToken: jest.fn(() => Promise.resolve('fake-token')) + getAccessToken: jest.fn(() => Promise.resolve('fake-token')), + getSyncItem: jest.fn(() => Promise.resolve(false)), + setWatchedRepos: jest.fn(() => Promise.resolve()) })); jest.unstable_mockModule('../shared/github-api.js', () => ({ @@ -23,7 +25,7 @@ jest.unstable_mockModule('../shared/icons.js', () => ({ createSvg: jest.fn(() => '') })); -const { getAccessToken } = await import('../shared/storage-helpers.js'); +const { getAccessToken, getSyncItem, setWatchedRepos } = await import('../shared/storage-helpers.js'); const { openImportModal, closeImportModal, @@ -88,6 +90,11 @@ describe('import-controller', () => { selectedCount.id = 'selectedCount'; modal.appendChild(selectedCount); + const selectAllCheckbox = document.createElement('input'); + selectAllCheckbox.id = 'selectAllImport'; + selectAllCheckbox.type = 'checkbox'; + modal.appendChild(selectAllCheckbox); + confirmBtn = document.createElement('button'); confirmBtn.id = 'confirmImportBtn'; confirmBtn.disabled = true; @@ -99,7 +106,7 @@ describe('import-controller', () => { document.body.appendChild(modal); - // Mock chrome.storage + // Mock chrome.storage for browser-only access paths global.chrome = { storage: { sync: { @@ -494,7 +501,7 @@ describe('import-controller', () => { expect(onReposAdded).toHaveBeenCalled(); }); - test('saves to chrome storage', async () => { + test('saves to sync storage helper', async () => { reposContainer.innerHTML = `
  • Repo 1
  • `; @@ -503,11 +510,11 @@ describe('import-controller', () => { await importSelectedRepos(watchedRepos); - expect(chrome.storage.sync.set).toHaveBeenCalledWith({ - watchedRepos: expect.arrayContaining([ + expect(setWatchedRepos).toHaveBeenCalledWith( + expect.arrayContaining([ expect.objectContaining({ fullName: 'owner/repo1' }) ]) - }); + ); }); test('does nothing when no repos selected', async () => { @@ -520,7 +527,7 @@ describe('import-controller', () => { await importSelectedRepos(watchedRepos); expect(watchedRepos.length).toBe(0); - expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + expect(setWatchedRepos).not.toHaveBeenCalled(); }); test('excludes already-added repos from import', async () => { @@ -547,6 +554,32 @@ describe('import-controller', () => { expect(modal.classList.contains('show')).toBe(false); }); + + test('does not mutate watched repos when the storage write fails', async () => { + reposContainer.innerHTML = ` +
  • Repo 1
  • + `; + setWatchedRepos.mockRejectedValueOnce(new Error('Storage quota exceeded. Please clear old data.')); + + const watchedRepos = []; + + await expect(importSelectedRepos(watchedRepos)).rejects.toThrow('Storage quota exceeded'); + expect(watchedRepos).toEqual([]); + }); + + test('rejects imports that exceed the repository limit when unlimited is off', async () => { + reposContainer.innerHTML = ` +
  • Repo 51
  • + `; + getSyncItem.mockResolvedValueOnce(false); + + const watchedRepos = Array.from({ length: 50 }, (_, index) => ({ + fullName: `owner/repo${index + 1}` + })); + + await expect(importSelectedRepos(watchedRepos)).rejects.toThrow('Import would exceed the 50 repository limit'); + expect(setWatchedRepos).not.toHaveBeenCalled(); + }); }); describe('keyboard navigation and accessibility', () => { From 64adcfdc8e805d2880d04f47884d5557ce2400d3 Mon Sep 17 00:00:00 2001 From: jonmartin721 Date: Thu, 12 Mar 2026 23:59:02 -0500 Subject: [PATCH 09/11] fix validation follow-up for oauth branch --- .github/workflows/ci.yml | 5 ++++- options/options.js | 12 ++---------- package-lock.json | 11 +++++++---- package.json | 4 ++-- tests/options-main.test.js | 20 +++++++++++++++++++- tests/state-manager.test.js | 2 ++ 6 files changed, 36 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 771e9a2..3314e15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,10 @@ name: CI on: push: - branches: [ main, master ] + branches: + - '**' + tags-ignore: + - 'v*' pull_request: branches: [ main, master ] diff --git a/options/options.js b/options/options.js index ae85829..808da7d 100644 --- a/options/options.js +++ b/options/options.js @@ -29,7 +29,6 @@ const state = { searchQuery: '', hidePinnedRepos: false }; -let persistedSession = null; if (typeof document !== 'undefined') { document.addEventListener('DOMContentLoaded', async () => { @@ -200,16 +199,10 @@ function setupEventListeners() { document.getElementById('addRepoBtn').addEventListener('click', addRepo); document.getElementById('connectGitHubBtn').addEventListener('click', async () => { - const connectionResult = await connectGitHub(toastManager); - if (connectionResult.isValid) { - persistedSession = connectionResult.authSession; - } + await connectGitHub(toastManager); }); document.getElementById('clearTokenBtn').addEventListener('click', async () => { - const tokenCleared = await clearToken(); - if (tokenCleared) { - persistedSession = null; - } + await clearToken(); }); // Action button toggles @@ -578,7 +571,6 @@ async function loadSettings() { applyTheme(theme); if (authSession?.accessToken) { - persistedSession = authSession; applyStoredConnection(authSession); } else { syncTokenUiWithStoredCredential(false); diff --git a/package-lock.json b/package-lock.json index 63481f1..4c4ac3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2289,13 +2289,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.29", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.29.tgz", - "integrity": "sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==", + "version": "2.10.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", + "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/brace-expansion": { diff --git a/package.json b/package.json index c5b60b4..25a8c3c 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", - "lint": "eslint .", - "typecheck": "tsc --noEmit -p jsconfig.json", + "lint": "node node_modules/eslint/bin/eslint.js .", + "typecheck": "node node_modules/typescript/bin/tsc --noEmit -p jsconfig.json", "build": "node scripts/build.js", "validate": "npm run lint && npm run typecheck && npm run test:coverage && npm run build", "format": "prettier --write \"**/*.{js,json,md}\"", diff --git a/tests/options-main.test.js b/tests/options-main.test.js index 65312a9..a07af8c 100644 --- a/tests/options-main.test.js +++ b/tests/options-main.test.js @@ -88,7 +88,25 @@ describe('Options Main Functions', () => { storage: { local: { get: jest.fn((keys, callback) => { - callback({ activities: [], readItems: [] }); + const requestedKeys = Array.isArray(keys) ? keys : [keys]; + const result = {}; + + requestedKeys.forEach((key) => { + if (key === 'activities') { + result.activities = []; + } + if (key === 'readItems') { + result.readItems = []; + } + if (key === 'githubOAuthClientId') { + result.githubOAuthClientId = 'Iv1.test-client-id'; + } + if (key === 'encryptedGithubAuthSession') { + result.encryptedGithubAuthSession = null; + } + }); + + callback(result); }), set: jest.fn((data, callback) => { if (callback) callback(); diff --git a/tests/state-manager.test.js b/tests/state-manager.test.js index d87f8d5..735fa34 100644 --- a/tests/state-manager.test.js +++ b/tests/state-manager.test.js @@ -21,6 +21,8 @@ global.chrome = { jest.unstable_mockModule('../shared/storage-helpers.js', () => ({ getSyncItems: jest.fn(async () => ({})), getLocalItems: jest.fn(async () => ({})), + getWatchedRepos: jest.fn(async () => []), + setWatchedRepos: jest.fn(async () => {}), STORAGE_KEYS: { SETTINGS: ['watchedRepos', 'mutedRepos', 'snoozedRepos', 'filters', 'notifications', 'checkInterval', 'theme', 'itemExpiryHours'], ACTIVITY: ['activities', 'readItems'] From cec1e3dd780e143c5e963f8d5aedf21c6dbd59b6 Mon Sep 17 00:00:00 2001 From: jonmartin721 Date: Thu, 12 Mar 2026 23:59:37 -0500 Subject: [PATCH 10/11] keep ci checks focused on pull requests --- .github/workflows/ci.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3314e15..771e9a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,7 @@ name: CI on: push: - branches: - - '**' - tags-ignore: - - 'v*' + branches: [ main, master ] pull_request: branches: [ main, master ] From 040192e020a9e296ab386071ab6a41153a98cadf Mon Sep 17 00:00:00 2001 From: jonmartin721 Date: Fri, 13 Mar 2026 00:04:32 -0500 Subject: [PATCH 11/11] ci: split pull request checks by job --- .github/workflows/ci.yml | 85 ++++++++++++++++++++++++++++++---------- 1 file changed, 65 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 771e9a2..d5164f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,34 +7,79 @@ on: branches: [ main, master ] jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + typecheck: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run type checker + run: npm run typecheck + test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '18' - cache: 'npm' + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '18' + cache: 'npm' - - name: Install dependencies - run: npm ci + - name: Install dependencies + run: npm ci - - name: Run linter - run: npm run lint + - name: Run tests with coverage + run: npm test -- --coverage --coverageReporters=text --coverageReporters=lcov - - name: Run type checker - run: npm run typecheck + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 - - name: Run tests with coverage - run: npm test -- --coverage --coverageReporters=text --coverageReporters=lcov + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '18' + cache: 'npm' - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} + - name: Install dependencies + run: npm ci - - name: Validate extension build - run: npm run build + - name: Validate extension build + run: npm run build