Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ Dashboard SPA tracking GitHub issues, PRs, and GHA workflow runs across multiple
- **Pull Requests Tab** — Open PRs with CI check status indicators (green/yellow/red dots). Draft badges, reviewer names.
- **Actions Tab** — GHA workflow runs grouped by repo and workflow. Accordion collapse, PR run toggle.
- **Onboarding Wizard** — Two-step org/repo selection with search filtering and bulk select.
- **Settings Page** — Refresh interval, notification preferences, theme (light/dark/system), density, GitHub Actions limits.
- **PAT Authentication** — Optional Personal Access Token login as alternative to OAuth. Client-side format validation, detailed token creation instructions for classic and fine-grained PATs.
- **Settings Page** — Refresh interval, notification preferences, theme (light/dark/system), density, GitHub Actions limits. Shows current auth method and hides OAuth-specific options for PAT users.
- **Desktop Notifications** — New item alerts with per-type toggles and batching.
- **Ignore System** — Hide specific items with an "N ignored" badge and unignore popover.
- **Dark Mode** — System-aware with flash prevention via inline script + CSP SHA-256 hash.
Expand All @@ -30,7 +31,7 @@ Dashboard SPA tracking GitHub issues, PRs, and GHA workflow runs across multiple
```sh
pnpm install
pnpm run dev # Start Vite dev server
pnpm test # Run browser tests (130 tests)
pnpm test # Run unit/component tests
pnpm run typecheck # TypeScript check
pnpm run build # Production build (~241KB JS, ~31KB CSS)
```
Expand All @@ -57,6 +58,7 @@ src/
config.ts # Zod v4-validated config with localStorage persistence
view.ts # View state (tabs, sorting, ignored items, filters)
lib/
pat.ts # PAT format validation and token creation instruction constants
notifications.ts # Desktop notification permission, detection, and dispatch
worker/
index.ts # OAuth token exchange endpoint, CORS, security headers
Expand All @@ -72,6 +74,7 @@ tests/
## Security

- Strict CSP: `script-src 'self'` (SHA-256 exception for dark mode script only)
- PAT tokens stored in `localStorage` (same key as OAuth tokens) — single-user personal dashboard threat model
- OAuth CSRF protection via `crypto.getRandomValues` state parameter
- CORS locked to exact origin (strict equality, no substring matching)
- Access token stored in `localStorage` under app-specific key; CSP prevents XSS token theft
Expand Down
46 changes: 28 additions & 18 deletions src/app/components/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,26 +260,28 @@ export default function SettingsPage() {
</div>
</Show>

<div class="border-t border-base-300 pt-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-base-content">
Organization Access
</p>
<p class="text-xs text-base-content/60">
Request access for restricted orgs on GitHub — new orgs sync when you return
</p>
<Show when={config.authMethod !== "pat"}>
<div class="border-t border-base-300 pt-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-base-content">
Organization Access
</p>
<p class="text-xs text-base-content/60">
Request access for restricted orgs on GitHub — new orgs sync when you return
</p>
</div>
<button
type="button"
onClick={handleGrantOrgs}
disabled={merging()}
class="btn btn-sm btn-outline"
>
{merging() ? "Syncing..." : "Manage org access"}
</button>
</div>
<button
type="button"
onClick={handleGrantOrgs}
disabled={merging()}
class="btn btn-sm btn-outline"
>
{merging() ? "Syncing..." : "Manage org access"}
</button>
</div>
</div>
</Show>

<div class="border-t border-base-300 pt-3">
<div class="flex items-center justify-between">
Expand Down Expand Up @@ -561,6 +563,14 @@ export default function SettingsPage() {

{/* Section 7: Data */}
<Section title="Data">
{/* Authentication method */}
<SettingRow
label="Authentication"
description="Current sign-in method"
>
<span class="text-sm">{config.authMethod === "pat" ? "Personal Access Token" : "OAuth"}</span>
</SettingRow>

{/* Clear cache */}
<SettingRow
label="Clear cache"
Expand Down
36 changes: 36 additions & 0 deletions src/app/lib/pat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export type PatValidationResult =
| { valid: true }
| { valid: false; error: string };

export function isValidPatFormat(token: string): PatValidationResult {
const trimmed = token.trim();
if (trimmed.length === 0) {
return { valid: false, error: "Please enter a token" };
}

const isClassic = trimmed.startsWith("ghp_");
const isFineGrained = trimmed.startsWith("github_pat_");

if (!isClassic && !isFineGrained) {
return { valid: false, error: "Token should start with ghp_ (classic) or github_pat_ (fine-grained)" };
}

const prefix = isClassic ? "ghp_" : "github_pat_";
const payload = trimmed.slice(prefix.length);

if (!/^[A-Za-z0-9_]*$/.test(payload)) {
return { valid: false, error: "Token contains invalid characters — check that you copied it correctly" };
}

// Classic PATs are exactly 40 chars. Fine-grained PATs are ~93 chars;
// use 80 as a safe lower bound to catch clearly truncated tokens.
const minLength = isClassic ? 40 : 80;
if (trimmed.length < minLength) {
return { valid: false, error: "Token appears truncated — check that you copied the full value" };
}

return { valid: true };
}

export const GITHUB_PAT_URL = "https://github.com/settings/tokens/new";
export const GITHUB_FINE_GRAINED_PAT_URL = "https://github.com/settings/personal-access-tokens/new";
191 changes: 168 additions & 23 deletions src/app/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,184 @@
import { createSignal, Show } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { setAuthFromPat, type GitHubUser } from "../stores/auth";
import {
isValidPatFormat,
GITHUB_PAT_URL,
GITHUB_FINE_GRAINED_PAT_URL,
} from "../lib/pat";
import { buildAuthorizeUrl } from "../lib/oauth";

export default function LoginPage() {
const navigate = useNavigate();

const [showPatForm, setShowPatForm] = createSignal(false);
const [patInput, setPatInput] = createSignal("");
const [patError, setPatError] = createSignal<string | null>(null);
const [submitting, setSubmitting] = createSignal(false);

function handleLogin() {
window.location.href = buildAuthorizeUrl();
}

async function handlePatSubmit(e: Event) {
e.preventDefault();
if (submitting()) return;
const validation = isValidPatFormat(patInput());
if (!validation.valid) {
setPatError(validation.error);
return;
}
setSubmitting(true);
setPatError(null);
const trimmedToken = patInput().trim();
try {
const resp = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${trimmedToken}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!resp.ok) {
setPatError(
resp.status === 401
? "Token is invalid — check that you entered it correctly"
: `GitHub returned ${resp.status} — try again later`
);
return;
}
if (!showPatForm()) return;
const userData = (await resp.json()) as GitHubUser;
setAuthFromPat(trimmedToken, userData);
setPatInput("");
navigate("/", { replace: true });
} catch {
setPatError("Network error — please try again");
} finally {
setSubmitting(false);
}
}

return (
<div class="bg-base-200 min-h-screen flex items-center justify-center">
<div class="card bg-base-100 shadow-xl max-w-sm w-full mx-4">
<div class="card-body items-center text-center gap-6">
<div class="flex flex-col items-center gap-2">
<h1 class="card-title text-2xl">
GitHub Tracker
</h1>
<p class="text-sm text-base-content/60 text-center">
Track issues, pull requests, and workflow runs across your GitHub
repositories.
</p>
</div>

<button
type="button"
onClick={handleLogin}
class="btn btn-neutral w-full"

<Show
when={!showPatForm()}
fallback={
<form onSubmit={(e) => void handlePatSubmit(e)} class="w-full flex flex-col gap-4">
<h2 class="card-title">Sign in with Token</h2>

<div class="text-left w-full">
<label for="pat-input" class="label">
<span class="label-text">Personal access token</span>
</label>
<input
id="pat-input"
type="password"
autocomplete="new-password"
placeholder="ghp_... or github_pat_..."
class={`input input-bordered w-full${patError() !== null ? " input-error" : ""}`}
aria-invalid={patError() !== null}
aria-describedby={patError() !== null ? "pat-error" : undefined}
value={patInput()}
onInput={(e) => setPatInput(e.currentTarget.value)}
/>
<Show when={patError() !== null}>
<p id="pat-error" role="alert" class="text-error text-xs mt-1">
{patError()}
</p>
</Show>
</div>

<button
type="submit"
class="btn btn-neutral w-full"
disabled={submitting()}
>
{submitting() ? "Verifying..." : "Sign in"}
</button>

<div class="text-left text-xs space-y-3 mt-4">
<div>
<p class="font-medium mb-1">
<a
href={GITHUB_PAT_URL}
target="_blank"
rel="noopener noreferrer"
class="link link-primary"
>
Classic token
</a>
{" "}(recommended) — works across all orgs. Select these scopes:
</p>
<ul class="list-disc list-inside space-y-0.5 text-base-content/70">
<li><code>repo</code></li>
<li><code>read:org</code> <span class="text-base-content/40">(under admin:org)</span></li>
<li><code>notifications</code></li>
</ul>
</div>

<p class="text-base-content/50">
<a
href={GITHUB_FINE_GRAINED_PAT_URL}
target="_blank"
rel="noopener noreferrer"
class="link"
>
Fine-grained tokens
</a>
{" "}also work, but only access one org at a time and do not support notifications. Add read-only permissions for Actions, Contents, Issues, and Pull requests.
</p>
</div>

<button
type="button"
onClick={() => { setShowPatForm(false); setPatError(null); setPatInput(""); }}
class="link link-primary text-sm mt-2"
>
Use OAuth instead
</button>
</form>
}
>
<svg
viewBox="0 0 16 16"
class="w-5 h-5"
aria-hidden="true"
fill="currentColor"
<div class="flex flex-col items-center gap-2">
<h1 class="card-title text-2xl">
GitHub Tracker
</h1>
<p class="text-sm text-base-content/60 text-center">
Track issues, pull requests, and workflow runs across your GitHub
repositories.
</p>
</div>

<button
type="button"
onClick={handleLogin}
class="btn btn-neutral w-full"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
Sign in with GitHub
</button>
<svg
viewBox="0 0 16 16"
class="w-5 h-5"
aria-hidden="true"
fill="currentColor"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
Sign in with GitHub
</button>

<div class="divider text-xs text-base-content/40">or</div>
<button
type="button"
onClick={() => setShowPatForm(true)}
class="link link-primary text-sm"
>
Use a Personal Access Token
</button>
</Show>

</div>
</div>
</div>
Expand Down
4 changes: 3 additions & 1 deletion src/app/services/poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ async function hasNotificationChanges(): Promise<boolean> {
(err as { status?: number }).status === 403
) {
console.warn("[poll] Notifications API returned 403 — disabling gate");
pushNotification("notifications", "Notifications API returned 403 — check that the notifications scope is granted", "warning");
pushNotification("notifications", config.authMethod === "pat"
? "Notifications API returned 403 — fine-grained tokens do not support notifications; classic tokens need the notifications scope"
: "Notifications API returned 403 — check that the notifications scope is granted", "warning");
_notifGateDisabled = true;
}
return true;
Expand Down
16 changes: 13 additions & 3 deletions src/app/stores/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createSignal } from "solid-js";
import { clearCache } from "./cache";
import { CONFIG_STORAGE_KEY, resetConfig } from "./config";
import { CONFIG_STORAGE_KEY, resetConfig, updateConfig, config } from "./config";
import { VIEW_STORAGE_KEY, resetViewState } from "./view";

export const AUTH_STORAGE_KEY = "github-tracker:auth-token";
Expand Down Expand Up @@ -45,6 +45,12 @@ export function setAuth(response: TokenExchangeResponse): void {
console.info("[auth] access token set (localStorage)");
}

export function setAuthFromPat(token: string, userData: GitHubUser): void {
setAuth({ access_token: token });
setUser({ login: userData.login, avatar_url: userData.avatar_url, name: userData.name });
updateConfig({ authMethod: "pat" });
}

const _onClearCallbacks: (() => void)[] = [];

/** Register a callback to run when auth is cleared. Avoids circular imports. */
Expand Down Expand Up @@ -107,8 +113,12 @@ export async function validateToken(): Promise<boolean> {
}

if (resp.status === 401) {
// Permanent token is revoked — clear auth and redirect to login
console.info("[auth] access token invalid — clearing auth");
const method = config.authMethod;
console.info(
method === "pat"
? "[auth] PAT invalid or expired — clearing auth"
: "[auth] access token invalid — clearing auth"
);
clearAuth();
return false;
}
Expand Down
1 change: 1 addition & 0 deletions src/app/stores/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const ConfigSchema = z.object({
defaultTab: z.enum(["issues", "pullRequests", "actions"]).default("issues"),
rememberLastTab: z.boolean().default(true),
onboardingComplete: z.boolean().default(false),
authMethod: z.enum(["oauth", "pat"]).default("oauth"),
});

export type Config = z.infer<typeof ConfigSchema>;
Expand Down
Loading
Loading