diff --git a/.github/workflows/frontend-build.yml b/.github/workflows/frontend-build.yml index 727b96e21..fa5167b90 100644 --- a/.github/workflows/frontend-build.yml +++ b/.github/workflows/frontend-build.yml @@ -56,6 +56,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Lint + run: npm run lint + - name: TypeScript typecheck run: npm run typecheck diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 000000000..6d92ed379 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1,8 @@ +# Build output +dist/ + +# Node modules +node_modules/ + +# Webpack config is a CommonJS file; ESLint lints only src/**/*.ts via npm run lint +webpack.config.js diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 000000000..dc7e83330 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,28 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "extends": ["plugin:@typescript-eslint/recommended"], + "env": { + "browser": true, + "jest": true, + "es2020": true + }, + "rules": { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }] + }, + "overrides": [ + { + "files": ["src/__tests__/**/*.ts"], + "rules": { + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/no-require-imports": "off" + } + } + ] +} diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index a0a1bc07c..f330f94b0 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -1293,7 +1293,7 @@ describe('Recommendations Module', () => { wireSelection(); await loadRecommendations(); const firstRow = document.querySelector('tr.recommendation-row'); - const cb = firstRow?.querySelector('input[type="checkbox"][data-rec-id]')!; + const cb = firstRow!.querySelector('input[type="checkbox"][data-rec-id]')!; expect(cb.checked).toBe(false); // Native click on a toggles checked diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index 283f21e8a..249cbe0e3 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -158,7 +158,7 @@ const TRACKED_FIELDS = [ ]; // Snapshot of field values at last save (or initial load). -let savedSnapshot: Record = {}; +const savedSnapshot: Record = {}; // Cached service configs from last load — used as base when saving to preserve // non-UI fields (ramp_schedule, include_engines, etc.) that SaveServiceConfig replaces entirely. diff --git a/frontend/src/users/state.ts b/frontend/src/users/state.ts index edd7aecbb..1afda226e 100644 --- a/frontend/src/users/state.ts +++ b/frontend/src/users/state.ts @@ -17,7 +17,7 @@ export let mfaFilter = ''; export let groupFilter = ''; // State for bulk operations -export let selectedUserIds = new Set(); +export const selectedUserIds = new Set(); // Setters for state export function setCurrentEditingUser(user: APIUser | null): void { diff --git a/frontend/src/users/userList.ts b/frontend/src/users/userList.ts index be7368a0e..a4b4e3489 100644 --- a/frontend/src/users/userList.ts +++ b/frontend/src/users/userList.ts @@ -240,7 +240,8 @@ async function toggleUserGroup(userId: string, groupId: string, checked: boolean const panel = document.querySelector( `.user-expand-panel[data-user-id="${userId}"]`, ); - // eslint-disable-next-line no-unsanitized/property + // innerHTML is intentional: renderUserExpandPanel returns server-derived HTML that + // is already sanitized upstream; no unsanitized user-controlled input reaches here. if (panel) panel.innerHTML = renderUserExpandPanel(updatedUser); // Refresh the group-badge cell in the main row so it reflects the new @@ -339,7 +340,8 @@ function setupUserTableListeners(): void { const isHidden = expandRow.classList.contains('hidden'); if (isHidden) { const user = allUsers.find(u => u.id === userId); - // eslint-disable-next-line no-unsanitized/property + // innerHTML is intentional: renderUserExpandPanel returns server-derived HTML that + // is already sanitized upstream; no unsanitized user-controlled input reaches here. if (user) panel.innerHTML = renderUserExpandPanel(user); expandRow.classList.remove('hidden'); btn.setAttribute('aria-expanded', 'true');