diff --git a/.env.example b/.env.example index 9fb11745..29423e15 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# GitHub App client ID — embedded into client-side bundle at build time by Vite. +# GitHub OAuth App client ID — embedded into client-side bundle at build time by Vite. # This is public information (visible in the OAuth authorize URL). # Set this as a GitHub Actions variable (not a secret) for CI/CD. -VITE_GITHUB_CLIENT_ID=your_github_app_client_id_here +VITE_GITHUB_CLIENT_ID=your_oauth_app_client_id_here diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ab287a40 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI +on: + pull_request: +permissions: + contents: read +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run typecheck + - run: pnpm test + - name: Verify CSP hash + run: node scripts/verify-csp-hash.mjs + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + - name: Run E2E tests + run: pnpm test:e2e + env: + VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }} diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml deleted file mode 100644 index 44e6b4e6..00000000 --- a/.github/workflows/preview.yml +++ /dev/null @@ -1,101 +0,0 @@ -name: Preview -on: - pull_request: -permissions: - contents: read - pull-requests: write -jobs: - ci: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm run typecheck - - run: pnpm test - - name: Verify CSP hash - run: node scripts/verify-csp-hash.mjs - - name: Install Playwright browsers - run: npx playwright install chromium --with-deps - - name: Run E2E tests - run: pnpm test:e2e - env: - VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }} - preview: - needs: ci - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Verify CSP hash - run: node scripts/verify-csp-hash.mjs - - run: pnpm run build - env: - VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }} - - name: Slugify branch name - id: slug - env: - BRANCH: ${{ github.head_ref }} - run: echo "alias=$(echo "$BRANCH" | sed 's/[^a-zA-Z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//' | tr 'A-Z' 'a-z' | sed 's/^[^a-z]*//' | cut -c1-48 | sed 's/-$//; s/^$/preview/')" >> "$GITHUB_OUTPUT" - - name: Upload preview version - id: preview - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: versions upload --preview-alias ${{ steps.slug.outputs.alias }} - - name: Comment preview URL - uses: actions/github-script@v7 - env: - WRANGLER_OUTPUT: ${{ steps.preview.outputs.command-output }} - BRANCH_ALIAS: ${{ steps.slug.outputs.alias }} - BRANCH_NAME: ${{ github.head_ref }} - with: - script: | - const output = process.env.WRANGLER_OUTPUT || ''; - const urlMatch = output.match(/https:\/\/[^\s"'`]+\.workers\.dev[^\s"'`]*/); - const alias = process.env.BRANCH_ALIAS; - const branch = process.env.BRANCH_NAME; - const url = urlMatch ? urlMatch[0] : `Preview alias: ${alias} (check workflow logs for URL)`; - const marker = ''; - const body = [ - '### Preview Deployment', - '', - urlMatch ? `[${url}](${url})` : url, - '', - `Branch: \`${branch}\` | Alias: \`${alias}\``, - '', - marker, - ].join('\n'); - - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - const existing = comments.find(c => c.body?.includes(marker)); - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, - }); - } diff --git a/DEPLOY.md b/DEPLOY.md index 56782b51..11d63cfa 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -20,63 +20,38 @@ ### Variables (GitHub repo → Settings → Secrets and variables → Actions → Variables) **`VITE_GITHUB_CLIENT_ID`** -- This is the GitHub App Client ID (not a secret — it is embedded in the built JS bundle) +- This is the GitHub OAuth App Client ID (not a secret — it is embedded in the built JS bundle) - Add it as an Actions **variable** (not a secret) -- See GitHub App setup below for how to obtain it +- See OAuth App setup below for how to obtain it -## GitHub App Setup +## GitHub OAuth App Setup -1. Go to GitHub → Settings → Developer settings → GitHub Apps → **New GitHub App** -2. Fill in the basic details: - - **App name**: your app name (e.g. `gh-tracker-yourname`) - - **Description**: `Personal dashboard for tracking GitHub issues, PRs, and Actions runs across repos and orgs.` +1. Go to GitHub → Settings → Developer settings → OAuth Apps → **New OAuth App** +2. Fill in the details: + - **Application name**: your app name (e.g. `gh-tracker-yourname`) - **Homepage URL**: `https://gh.gordoncode.dev` -3. Under **Identifying and authorizing users**: - - **Callback URLs** — register all three: - - `https://gh.gordoncode.dev/oauth/callback` (production) - - `https://github-tracker..workers.dev/oauth/callback` (preview — GitHub's subdomain matching should allow per-branch preview aliases like `alias.github-tracker..workers.dev` to work; verify after first preview deploy) - - `http://localhost:5173/oauth/callback` (local dev) - - ✅ **Expire user authorization tokens** — check this. The app uses short-lived access tokens (8hr) with HttpOnly cookie-based refresh token rotation. - - ✅ **Request user authorization (OAuth) during installation** — check this. Streamlines the install + authorize flow into one step. -4. Under **Post installation**: - - Leave **Setup URL** blank - - Leave **Redirect on update** unchecked -5. Under **Webhook**: - - ❌ Uncheck **Active** — the app polls; it does not use webhooks. -6. Under **Permissions**: - - **Repository permissions** (read-only): - - | Permission | Access | Used for | - |------------|--------|----------| - | **Actions** | Read-only | `GET /repos/{owner}/{repo}/actions/runs` — workflow run list | - | **Checks** | Read-only | `GET /repos/{owner}/{repo}/commits/{ref}/check-runs` — PR check status (REST fallback) | - | **Commit statuses** | Read-only | `GET /repos/{owner}/{repo}/commits/{ref}/status` — legacy commit status (REST fallback) | - | **Issues** | Read-only | `GET /search/issues?q=is:issue` — issue search | - | **Metadata** | Read-only | Automatically granted when any repo permission is set. Required for basic repo info. | - | **Pull requests** | Read-only | `GET /search/issues?q=is:pr`, `GET /repos/{owner}/{repo}/pulls/{pull_number}`, `/reviews` — PR search, detail, and reviews | - - **Organization permissions:** - - | Permission | Access | Used for | - |------------|--------|----------| - | **Members** | Read-only | `GET /user/orgs` — list user's organizations for the org selector | - - **Account permissions:** - - | Permission | Access | Used for | - |------------|--------|----------| - | _(none required)_ | | | - -7. Under **Where can this GitHub App be installed?**: - - **Any account** — the app uses OAuth authorization (not installation tokens), so any GitHub user needs to be able to authorize via the login flow -8. Click **Create GitHub App** -9. Note the **Client ID** — this is your `VITE_GITHUB_CLIENT_ID` -10. Click **Generate a new client secret** and save it for the Worker secrets below - -### Notifications API limitation - -The GitHub Notifications API (`GET /notifications`) does not support GitHub App user access tokens — only classic personal access tokens. The app uses notifications as a polling optimization gate (skip full fetch when nothing changed). When the notifications endpoint returns 403, the gate **auto-disables** and the app falls back to time-based polling. No functionality is lost; polling is just slightly less efficient. + - **Authorization callback URL**: `https://gh.gordoncode.dev/oauth/callback` +3. Click **Register application** +4. Note the **Client ID** — this is your `VITE_GITHUB_CLIENT_ID` +5. Click **Generate a new client secret** and save it for the Worker secrets below + +### Scopes + +The login flow requests `scope=repo read:org notifications`: + +| Scope | Used for | +|-------|----------| +| `repo` | Read issues, PRs, check runs, workflow runs (includes private repos) | +| `read:org` | `GET /user/orgs` — list user's organizations for the org selector | +| `notifications` | `GET /notifications` — polling optimization gate (304 = skip full fetch) | + +**Note:** The `repo` scope grants write access to repositories, but this app never performs write operations (POST/PUT/PATCH/DELETE on repo endpoints). It is read-only by design. + +### Local development OAuth App + +Create a second OAuth App for local development: +- **Authorization callback URL**: `http://localhost:5173/oauth/callback` +- Set its Client ID and Secret in `.dev.vars` (see Local Development below) ## Cloudflare Worker Secrets @@ -91,41 +66,30 @@ wrangler secret put ALLOWED_ORIGIN ``` - `GITHUB_CLIENT_ID`: same value as `VITE_GITHUB_CLIENT_ID` -- `GITHUB_CLIENT_SECRET`: the Client Secret from your GitHub App +- `GITHUB_CLIENT_SECRET`: the Client Secret from your GitHub OAuth App - `ALLOWED_ORIGIN`: `https://gh.gordoncode.dev` -### Preview versions - -Preview deployments use `wrangler versions upload` (not a separate environment), so they inherit production secrets automatically. No additional secret configuration is needed. - -CORS note: Preview URLs are same-origin (SPA and API share the same `*.workers.dev` host), so the `ALLOWED_ORIGIN` strict-equality check is irrelevant — browsers don't enforce CORS on same-origin requests. - -**Migration note:** If you previously deployed with `wrangler deploy --env preview`, an orphaned `github-tracker-preview` worker may still exist. Delete it via `wrangler delete --name github-tracker-preview` or through the Cloudflare dashboard. - ## Worker API Endpoints -| Endpoint | Method | Auth | Purpose | -|----------|--------|------|---------| -| `/api/oauth/token` | POST | none | Exchange OAuth code for access token. Refresh token set as HttpOnly cookie. | -| `/api/oauth/refresh` | POST | cookie | Refresh expired access token. Reads `github_tracker_rt` HttpOnly cookie. Sets rotated cookie. | -| `/api/oauth/logout` | POST | none | Clears the `github_tracker_rt` HttpOnly cookie (`Max-Age=0`). | -| `/api/health` | GET | none | Health check. Returns `OK`. | +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/api/oauth/token` | POST | Exchange OAuth authorization code for permanent access token. | +| `/api/health` | GET | Health check. Returns `OK`. | -### Refresh Token Security +### Token Storage Security -The refresh token (6-month lifetime) is stored as an **HttpOnly cookie** — never in `localStorage` or the response body. This protects the high-value long-lived credential from XSS: +The OAuth App access token is a permanent credential (no expiry). It is stored in `localStorage` under the key `github-tracker:auth-token`: -- Production cookie: `__Host-github_tracker_rt` with `HttpOnly; Secure; SameSite=Strict; Path=/` -- Local dev: `github_tracker_rt` with `HttpOnly; SameSite=Lax; Path=/` (no `Secure` — localhost is HTTP; no `__Host-` prefix — requires `Secure`) -- The short-lived access token (8hr) is held in-memory only (never persisted to `localStorage`); on page reload, `refreshAccessToken()` obtains a fresh token via the cookie -- On logout, the client calls `POST /api/oauth/logout` to clear the cookie -- GitHub rotates the refresh token on each use; the Worker sets the new value as a cookie +- **CSP protects against XSS token theft**: `script-src 'self'` prevents injection of unauthorized scripts that could read `localStorage` +- On page load, `validateToken()` calls `GET /user` to verify the token is still valid +- On 401, the app immediately clears auth and redirects to login (token is revoked, not expired) +- On logout, the token is removed from `localStorage` and all local state is cleared +- Transient network errors do NOT clear the token (permanent tokens survive connectivity issues) ### CORS - `Access-Control-Allow-Origin`: exact match against `ALLOWED_ORIGIN` (no wildcards) -- `Access-Control-Allow-Credentials: true`: enables cookie-based refresh for cross-origin preview deploys -- Same-origin requests (production, local dev) send cookies automatically without CORS +- No `Access-Control-Allow-Credentials` header (OAuth App uses no cookies) ## Local Development @@ -138,9 +102,15 @@ pnpm run build wrangler deploy ``` -For preview (uploads a version without promoting to production): +## Migration from GitHub App -```sh -pnpm run build -wrangler versions upload --preview-alias my-feature -``` +If you previously deployed with the GitHub App model (HttpOnly cookie refresh tokens), follow these steps: + +1. **Update GitHub Actions variable**: change `VITE_GITHUB_CLIENT_ID` to your OAuth App's Client ID +2. **Update Cloudflare secrets**: re-run `wrangler secret put GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` with OAuth App values +3. **Update `ALLOWED_ORIGIN`** if it changed (usually unchanged) +4. **Redeploy** the Worker: `pnpm run build && wrangler deploy` +5. **Existing users** will be logged out on next page load (their refresh cookie is no longer valid; they will be prompted to log in again via the new OAuth App flow) +6. **Delete the old GitHub App** (optional): GitHub → Settings → Developer settings → GitHub Apps → your app → Advanced → Delete + +The old `POST /api/oauth/refresh` and `POST /api/oauth/logout` endpoints no longer exist and return 404. diff --git a/README.md b/README.md index 18f56753..c5f9dccb 100644 --- a/README.md +++ b/README.md @@ -52,14 +52,14 @@ src/ github.ts # Octokit client factory with ETag caching and rate limit tracking poll.ts # Poll coordinator with visibility-aware auto-refresh stores/ - auth.ts # OAuth token management with auto-refresh + auth.ts # OAuth token management (localStorage persistence, validateToken) cache.ts # IndexedDB cache with TTL eviction and ETag support config.ts # Zod v4-validated config with localStorage persistence view.ts # View state (tabs, sorting, ignored items, filters) lib/ notifications.ts # Desktop notification permission, detection, and dispatch worker/ - index.ts # OAuth token exchange/refresh endpoint, CORS, security headers + index.ts # OAuth token exchange endpoint, CORS, security headers tests/ fixtures/ # GitHub API response fixtures (orgs, repos, issues, PRs, runs) services/ # API service, Octokit client, and poll coordinator tests @@ -74,10 +74,11 @@ tests/ - Strict CSP: `script-src 'self'` (SHA-256 exception for dark mode script only) - OAuth CSRF protection via `crypto.getRandomValues` state parameter - CORS locked to exact origin (strict equality, no substring matching) -- Access token in-memory only (never persisted); refresh token in `__Host-` HttpOnly cookie -- Auto-refresh on 401 and on page load via HttpOnly cookie +- Access token stored in `localStorage` under app-specific key; CSP prevents XSS token theft +- Token validation on page load via `GET /user`; 401 clears auth immediately (no silent refresh) - All GitHub API strings auto-escaped by SolidJS JSX (no innerHTML) +- `repo` scope granted (required for private repos) — app never performs write operations ## Deployment -See [DEPLOY.md](./DEPLOY.md) for Cloudflare, GitHub App, and CI/CD setup. +See [DEPLOY.md](./DEPLOY.md) for Cloudflare, OAuth App, and CI/CD setup. diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 1c9ef115..af6c2472 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -1,17 +1,11 @@ import { test, expect, type Page } from "@playwright/test"; /** - * Register API route interceptors and inject config BEFORE any navigation. - * The app calls refreshAccessToken() on load, which POSTs to /api/oauth/refresh - * (HttpOnly cookie-based). We intercept that to return a valid access token. + * Register API route interceptors and inject auth + config into localStorage BEFORE navigation. + * OAuth App uses permanent tokens stored in localStorage — no refresh endpoint needed. + * The app calls validateToken() on load, which GETs /user to verify the token. */ async function setupAuth(page: Page) { - await page.route("**/api/oauth/refresh", (route) => - route.fulfill({ - status: 200, - json: { access_token: "ghu_fake", expires_in: 86400 }, - }) - ); await page.route("https://api.github.com/user", (route) => route.fulfill({ status: 200, @@ -36,11 +30,12 @@ async function setupAuth(page: Page) { ); await page.addInitScript(() => { + localStorage.setItem("github-tracker:auth-token", "ghu_fake"); localStorage.setItem( "github-tracker:config", JSON.stringify({ selectedOrgs: ["testorg"], - selectedRepos: [{ owner: "testorg", name: "testrepo" }], + selectedRepos: [{ owner: "testorg", name: "testrepo", fullName: "testorg/testrepo" }], onboardingComplete: true, }) ); @@ -114,16 +109,17 @@ test("sign out clears auth and redirects to login", async ({ page }) => { const signOutBtn = page.getByRole("button", { name: /^sign out$/i }); await expect(signOutBtn).toBeVisible(); - // Intercept the logout cookie-clearing request - await page.route("**/api/oauth/logout", (route) => - route.fulfill({ status: 200, json: { ok: true } }) - ); - await signOutBtn.click(); - // clearAuth() clears in-memory token and navigates to /login + // clearAuth() removes the localStorage token and navigates to /login await expect(page).toHaveURL(/\/login/); + // Verify auth token was cleared from localStorage + const authToken = await page.evaluate(() => + localStorage.getItem("github-tracker:auth-token") + ); + expect(authToken).toBeNull(); + // Verify config was reset (SDR-016 data leakage prevention). // The persistence effect may re-write defaults, so check that user-specific // data (selectedOrgs, onboardingComplete) was cleared rather than checking null. diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index ba35a5ae..65369c0d 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,20 +1,12 @@ import { test, expect, type Page } from "@playwright/test"; /** - * Register API route interceptors and inject config BEFORE any navigation. - * The app calls refreshAccessToken() on load, which POSTs to /api/oauth/refresh - * (HttpOnly cookie-based). We intercept that to return a valid access token, - * and intercept GET /user (called by refreshAccessToken to validate the token). + * Register API route interceptors and inject auth + config into localStorage BEFORE navigation. + * OAuth App uses permanent tokens stored in localStorage — no refresh endpoint needed. + * The app calls validateToken() on load, which GETs /user to verify the token. */ async function setupAuth(page: Page) { - // Intercept refresh token exchange — app calls this on page load - await page.route("**/api/oauth/refresh", (route) => - route.fulfill({ - status: 200, - json: { access_token: "ghu_fake", expires_in: 86400 }, - }) - ); - // Intercept /user validation (called by refreshAccessToken after getting token) + // Intercept /user validation (called by validateToken on page load) await page.route("https://api.github.com/user", (route) => route.fulfill({ status: 200, @@ -46,13 +38,14 @@ async function setupAuth(page: Page) { route.fulfill({ status: 200, json: { data: {} } }) ); - // Inject config into localStorage (config is still persisted there) + // Seed localStorage with auth token and config before the page loads await page.addInitScript(() => { + localStorage.setItem("github-tracker:auth-token", "ghu_fake"); localStorage.setItem( "github-tracker:config", JSON.stringify({ selectedOrgs: ["testorg"], - selectedRepos: [{ owner: "testorg", name: "testrepo" }], + selectedRepos: [{ owner: "testorg", name: "testrepo", fullName: "testorg/testrepo" }], onboardingComplete: true, }) ); @@ -83,8 +76,8 @@ test("OAuth callback flow completes and redirects", async ({ page }) => { status: 200, json: { access_token: "ghu_fake", - refresh_token: "ghr_fake", - expires_in: 86400, + token_type: "bearer", + scope: "repo read:org notifications", }, }) ); @@ -105,7 +98,7 @@ test("OAuth callback flow completes and redirects", async ({ page }) => { "github-tracker:config", JSON.stringify({ selectedOrgs: ["testorg"], - selectedRepos: [{ owner: "testorg", name: "testrepo" }], + selectedRepos: [{ owner: "testorg", name: "testrepo", fullName: "testorg/testrepo" }], onboardingComplete: true, }) ); diff --git a/package.json b/package.json index c1c49548..dedb3c0d 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "test": "vitest run --config vitest.config.ts", - "test:watch": "vitest --config vitest.config.ts", + "test": "vitest run --config vitest.workspace.ts", + "test:watch": "vitest --config vitest.workspace.ts", "deploy": "wrangler deploy", "typecheck": "tsc --noEmit", "test:e2e": "playwright test" diff --git a/src/app/App.tsx b/src/app/App.tsx index d32d1e7c..b9b4347c 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,6 @@ import { createSignal, createEffect, onMount, Show, type JSX } from "solid-js"; import { Router, Route, Navigate, useNavigate } from "@solidjs/router"; -import { isAuthenticated, refreshAccessToken } from "./stores/auth"; +import { isAuthenticated, validateToken } from "./stores/auth"; import { config, initConfigPersistence } from "./stores/config"; import { initViewPersistence } from "./stores/view"; import { evictStaleEntries } from "./stores/cache"; @@ -13,14 +13,14 @@ import SettingsPage from "./components/settings/SettingsPage"; import PrivacyPage from "./pages/PrivacyPage"; // Auth guard: redirects unauthenticated users to /login. -// On page load (no in-memory token), attempts a silent refresh via HttpOnly cookie. +// On page load, validates the localStorage token with GitHub API. function AuthGuard(props: { children: JSX.Element }) { const [validating, setValidating] = createSignal(true); const navigate = useNavigate(); onMount(async () => { if (!isAuthenticated()) { - await refreshAccessToken(); + await validateToken(); } setValidating(false); }); @@ -71,7 +71,7 @@ function RootRedirect() { onMount(async () => { if (!isAuthenticated()) { - await refreshAccessToken(); + await validateToken(); } setValidating(false); }); diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index dc99036d..395e9ee2 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { createSignal, createMemo, Switch, Match, onMount } from "solid-js"; +import { createSignal, createMemo, Switch, Match, onMount, onCleanup } from "solid-js"; import { createStore } from "solid-js/store"; import Header from "../layout/Header"; import TabBar, { TabId } from "../layout/TabBar"; @@ -9,13 +9,15 @@ import PullRequestsTab from "./PullRequestsTab"; import { config } from "../../stores/config"; import { viewState, updateViewState } from "../../stores/view"; import type { Issue, PullRequest, WorkflowRun, ApiError } from "../../services/api"; -import { createPollCoordinator, fetchAllData } from "../../services/poll"; -import { refreshAccessToken, clearAuth, user } from "../../stores/auth"; +import { createPollCoordinator, fetchAllData, type DashboardData } from "../../services/poll"; +import { clearAuth, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../stores/auth"; import { getErrors, dismissError } from "../../lib/errors"; import ErrorBannerList from "../shared/ErrorBannerList"; // ── Shared dashboard store (module-level to survive navigation) ───────────── +const CACHE_VERSION = 1; + interface DashboardStore { issues: Issue[]; pullRequests: PullRequest[]; @@ -25,29 +27,92 @@ interface DashboardStore { lastRefreshedAt: Date | null; } -const [dashboardData, setDashboardData] = createStore({ +const initialDashboardState: DashboardStore = { issues: [], pullRequests: [], workflowRuns: [], errors: [], loading: true, lastRefreshedAt: null, +}; + +function loadCachedDashboard(): DashboardStore { + try { + const raw = localStorage.getItem?.(DASHBOARD_STORAGE_KEY); + if (!raw) return { ...initialDashboardState }; + const parsed = JSON.parse(raw) as Record; + // Invalidate cache on schema version mismatch + if (parsed._v !== CACHE_VERSION) return { ...initialDashboardState }; + // Validate expected shape — arrays must be arrays + if (!Array.isArray(parsed.issues) || !Array.isArray(parsed.pullRequests) || !Array.isArray(parsed.workflowRuns)) { + return { ...initialDashboardState }; + } + return { + issues: parsed.issues as Issue[], + pullRequests: parsed.pullRequests as PullRequest[], + workflowRuns: parsed.workflowRuns as WorkflowRun[], + errors: [], + loading: false, + lastRefreshedAt: typeof parsed.lastRefreshedAt === "string" ? new Date(parsed.lastRefreshedAt) : null, + }; + } catch { + return { ...initialDashboardState }; + } +} + +const [dashboardData, setDashboardData] = createStore(loadCachedDashboard()); + +function resetDashboardData(): void { + setDashboardData({ ...initialDashboardState }); + localStorage.removeItem?.(DASHBOARD_STORAGE_KEY); +} + +// Clear dashboard data and stop polling on logout to prevent cross-user data leakage +onAuthCleared(() => { + resetDashboardData(); + const coord = _coordinator(); + if (coord) { + coord.destroy(); + _setCoordinator(null); + } }); -async function pollFetch(): Promise { - setDashboardData("loading", true); +async function pollFetch(): Promise { + // Only show skeleton on initial load (no data yet). + // Subsequent refreshes keep existing data visible — the coordinator's + // isRefreshing signal handles the "Refreshing..." indicator. + if (!dashboardData.lastRefreshedAt) { + setDashboardData("loading", true); + } try { const data = await fetchAllData(); // When notifications gate says nothing changed, keep existing data if (!data.skipped) { + const now = new Date(); setDashboardData({ issues: data.issues, pullRequests: data.pullRequests, workflowRuns: data.workflowRuns, errors: data.errors, loading: false, - lastRefreshedAt: new Date(), + lastRefreshedAt: now, }); + // Persist for stale-while-revalidate on full page reload. + // Errors are transient and not persisted. Deferred to avoid blocking paint. + const cachePayload = { + _v: CACHE_VERSION, + issues: data.issues, + pullRequests: data.pullRequests, + workflowRuns: data.workflowRuns, + lastRefreshedAt: now.toISOString(), + }; + setTimeout(() => { + try { + localStorage.setItem(DASHBOARD_STORAGE_KEY, JSON.stringify(cachePayload)); + } catch { + // localStorage full or unavailable — non-fatal + } + }, 0); } else { setDashboardData("loading", false); } @@ -62,18 +127,17 @@ async function pollFetch(): Promise : null; if (status === 401) { - const refreshed = await refreshAccessToken(); - if (!refreshed) { - clearAuth(); - window.location.replace("/login"); - } + // Hard redirect (not navigate()) forces a full page reload, which clears + // module-level state like _coordinator and dashboardData for the next user. + clearAuth(); + window.location.replace("/login"); } setDashboardData("loading", false); throw err; } } -let _coordinator: ReturnType | null = null; +const [_coordinator, _setCoordinator] = createSignal | null>(null); export default function DashboardPage() { @@ -92,9 +156,13 @@ export default function DashboardPage() { } onMount(() => { - if (!_coordinator) { - _coordinator = createPollCoordinator(() => config.refreshInterval, pollFetch); + if (!_coordinator()) { + _setCoordinator(createPollCoordinator(() => config.refreshInterval, pollFetch)); } + onCleanup(() => { + _coordinator()?.destroy(); + _setCoordinator(null); + }); }); const tabCounts = createMemo(() => ({ @@ -118,9 +186,9 @@ export default function DashboardPage() { /> _coordinator?.manualRefresh()} + isRefreshing={_coordinator()?.isRefreshing() ?? dashboardData.loading} + lastRefreshedAt={_coordinator()?.lastRefreshAt() ?? dashboardData.lastRefreshedAt} + onRefresh={() => _coordinator()?.manualRefresh()} /> {/* Global error banner */} diff --git a/src/app/components/layout/FilterBar.tsx b/src/app/components/layout/FilterBar.tsx index 8a3513a0..d144109b 100644 --- a/src/app/components/layout/FilterBar.tsx +++ b/src/app/components/layout/FilterBar.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, For, onCleanup } from "solid-js"; +import { createMemo, createSignal, createEffect, For, onCleanup } from "solid-js"; import { config } from "../../stores/config"; import { viewState, setGlobalFilter } from "../../stores/view"; @@ -13,6 +13,16 @@ export default function FilterBar(props: FilterBarProps) { const tickTimer = setInterval(() => setTick((t) => t + 1), 30_000); onCleanup(() => clearInterval(tickTimer)); + // Fade out the "Updated X ago" label after 8 seconds + const [labelVisible, setLabelVisible] = createSignal(false); + createEffect(() => { + const ts = props.lastRefreshedAt; + if (!ts) return; + setLabelVisible(true); + const id = setTimeout(() => setLabelVisible(false), 8_000); + onCleanup(() => clearTimeout(id)); + }); + const orgs = createMemo(() => config.selectedOrgs); const repos = createMemo(() => { @@ -76,7 +86,11 @@ export default function FilterBar(props: FilterBarProps) {
{updatedLabel() && ( - + {updatedLabel()} )} diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index 8741bdfd..a3b667fe 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -251,13 +251,13 @@ export default function SettingsPage() { URL.revokeObjectURL(url); } - async function handleResetAll() { + function handleResetAll() { if (!confirmReset()) { setConfirmReset(true); return; } - // clearAuth handles: token + user signals, localStorage (config/view), - // HttpOnly cookie logout, IndexedDB cache, and onAuthCleared callbacks + // clearAuth handles: token + user signals, localStorage (auth/config/view), + // IndexedDB cache, and onAuthCleared callbacks clearAuth(); window.location.reload(); } @@ -637,7 +637,7 @@ export default function SettingsPage() { Are you sure?