Skip to content

Commit 8c47388

Browse files
committed
feat(auth): adds optional PAT authentication as alternative to OAuth
1 parent f6af41a commit 8c47388

File tree

11 files changed

+646
-124
lines changed

11 files changed

+646
-124
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ Dashboard SPA tracking GitHub issues, PRs, and GHA workflow runs across multiple
88
- **Pull Requests Tab** — Open PRs with CI check status indicators (green/yellow/red dots). Draft badges, reviewer names.
99
- **Actions Tab** — GHA workflow runs grouped by repo and workflow. Accordion collapse, PR run toggle.
1010
- **Onboarding Wizard** — Two-step org/repo selection with search filtering and bulk select.
11-
- **Settings Page** — Refresh interval, notification preferences, theme (light/dark/system), density, GitHub Actions limits.
11+
- **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.
12+
- **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.
1213
- **Desktop Notifications** — New item alerts with per-type toggles and batching.
1314
- **Ignore System** — Hide specific items with an "N ignored" badge and unignore popover.
1415
- **Dark Mode** — System-aware with flash prevention via inline script + CSP SHA-256 hash.
@@ -57,6 +58,7 @@ src/
5758
config.ts # Zod v4-validated config with localStorage persistence
5859
view.ts # View state (tabs, sorting, ignored items, filters)
5960
lib/
61+
pat.ts # PAT format validation and token creation instruction constants
6062
notifications.ts # Desktop notification permission, detection, and dispatch
6163
worker/
6264
index.ts # OAuth token exchange endpoint, CORS, security headers
@@ -72,6 +74,7 @@ tests/
7274
## Security
7375

7476
- Strict CSP: `script-src 'self'` (SHA-256 exception for dark mode script only)
77+
- PAT tokens stored in `localStorage` (same key as OAuth tokens) — single-user personal dashboard threat model
7578
- OAuth CSRF protection via `crypto.getRandomValues` state parameter
7679
- CORS locked to exact origin (strict equality, no substring matching)
7780
- Access token stored in `localStorage` under app-specific key; CSP prevents XSS token theft

src/app/components/settings/SettingsPage.tsx

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -260,26 +260,28 @@ export default function SettingsPage() {
260260
</div>
261261
</Show>
262262

263-
<div class="border-t border-base-300 pt-3">
264-
<div class="flex items-center justify-between">
265-
<div>
266-
<p class="text-sm font-medium text-base-content">
267-
Organization Access
268-
</p>
269-
<p class="text-xs text-base-content/60">
270-
Request access for restricted orgs on GitHub — new orgs sync when you return
271-
</p>
263+
<Show when={config.authMethod !== "pat"}>
264+
<div class="border-t border-base-300 pt-3">
265+
<div class="flex items-center justify-between">
266+
<div>
267+
<p class="text-sm font-medium text-base-content">
268+
Organization Access
269+
</p>
270+
<p class="text-xs text-base-content/60">
271+
Request access for restricted orgs on GitHub — new orgs sync when you return
272+
</p>
273+
</div>
274+
<button
275+
type="button"
276+
onClick={handleGrantOrgs}
277+
disabled={merging()}
278+
class="btn btn-sm btn-outline"
279+
>
280+
{merging() ? "Syncing..." : "Manage org access"}
281+
</button>
272282
</div>
273-
<button
274-
type="button"
275-
onClick={handleGrantOrgs}
276-
disabled={merging()}
277-
class="btn btn-sm btn-outline"
278-
>
279-
{merging() ? "Syncing..." : "Manage org access"}
280-
</button>
281283
</div>
282-
</div>
284+
</Show>
283285

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

562564
{/* Section 7: Data */}
563565
<Section title="Data">
566+
{/* Authentication method */}
567+
<SettingRow
568+
label="Authentication"
569+
description="Current sign-in method"
570+
>
571+
<span class="text-sm">{config.authMethod === "pat" ? "Personal Access Token" : "OAuth"}</span>
572+
</SettingRow>
573+
564574
{/* Clear cache */}
565575
<SettingRow
566576
label="Clear cache"

src/app/lib/pat.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export function isValidPatFormat(token: string): { valid: boolean; error?: string } {
2+
const trimmed = token.trim();
3+
if (trimmed.length === 0) {
4+
return { valid: false, error: "Please enter a token" };
5+
}
6+
7+
const isClassic = trimmed.startsWith("ghp_");
8+
const isFineGrained = trimmed.startsWith("github_pat_");
9+
10+
if (!isClassic && !isFineGrained) {
11+
return { valid: false, error: "Token should start with ghp_ (classic) or github_pat_ (fine-grained)" };
12+
}
13+
14+
const prefix = isClassic ? "ghp_" : "github_pat_";
15+
const payload = trimmed.slice(prefix.length);
16+
17+
if (!/^[A-Za-z0-9_]*$/.test(payload)) {
18+
return { valid: false, error: "Token contains invalid characters — check that you copied it correctly" };
19+
}
20+
21+
const minLength = isClassic ? 40 : 47;
22+
if (trimmed.length < minLength) {
23+
return { valid: false, error: "Token appears truncated — check that you copied the full value" };
24+
}
25+
26+
return { valid: true };
27+
}
28+
29+
export const PAT_FINE_GRAINED_PERMISSIONS = {
30+
repository: [
31+
"Actions: Read-only",
32+
"Contents: Read-only",
33+
"Issues: Read-only",
34+
"Metadata: Read-only",
35+
"Pull requests: Read-only",
36+
],
37+
} as const;
38+
39+
// Fine-grained PATs cannot access the Notifications API (GET /notifications returns 403).
40+
// The app gracefully handles this — the notifications gate auto-disables on 403.
41+
export const PAT_FINE_GRAINED_NOTIFICATIONS_CAVEAT =
42+
"Fine-grained tokens cannot access notifications — the app will skip notification-based polling optimization and still function correctly.";
43+
44+
export const GITHUB_PAT_URL = "https://github.com/settings/tokens/new";
45+
export const GITHUB_FINE_GRAINED_PAT_URL = "https://github.com/settings/personal-access-tokens/new";

src/app/pages/LoginPage.tsx

Lines changed: 188 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,204 @@
1+
import { createSignal, Show } from "solid-js";
2+
import { useNavigate } from "@solidjs/router";
3+
import { setAuthFromPat, validateToken } from "../stores/auth";
4+
import {
5+
isValidPatFormat,
6+
PAT_FINE_GRAINED_PERMISSIONS,
7+
GITHUB_PAT_URL,
8+
GITHUB_FINE_GRAINED_PAT_URL,
9+
PAT_FINE_GRAINED_NOTIFICATIONS_CAVEAT,
10+
} from "../lib/pat";
111
import { buildAuthorizeUrl } from "../lib/oauth";
212

313
export default function LoginPage() {
14+
const navigate = useNavigate();
15+
16+
const [showPatForm, setShowPatForm] = createSignal(false);
17+
const [patInput, setPatInput] = createSignal("");
18+
const [patError, setPatError] = createSignal<string | null>(null);
19+
const [submitting, setSubmitting] = createSignal(false);
20+
421
function handleLogin() {
522
window.location.href = buildAuthorizeUrl();
623
}
724

25+
async function handlePatSubmit(e: Event) {
26+
e.preventDefault();
27+
if (submitting()) return;
28+
const validation = isValidPatFormat(patInput());
29+
if (!validation.valid) {
30+
setPatError(validation.error!);
31+
return;
32+
}
33+
setSubmitting(true);
34+
setPatError(null);
35+
const trimmedToken = patInput().trim();
36+
try {
37+
// Validate token BEFORE storing to prevent half-set state and
38+
// distinguish network errors from invalid tokens
39+
const resp = await fetch("https://api.github.com/user", {
40+
headers: {
41+
Authorization: `Bearer ${trimmedToken}`,
42+
Accept: "application/vnd.github+json",
43+
"X-GitHub-Api-Version": "2022-11-28",
44+
},
45+
});
46+
if (!resp.ok) {
47+
setPatError(
48+
resp.status === 401
49+
? "Token is invalid — check that you entered it correctly"
50+
: `GitHub returned ${resp.status} — try again later`
51+
);
52+
setSubmitting(false);
53+
return;
54+
}
55+
if (!showPatForm()) {
56+
setSubmitting(false);
57+
return;
58+
}
59+
// Token is valid — store it and populate user data
60+
setAuthFromPat(trimmedToken);
61+
await validateToken();
62+
setPatInput("");
63+
setSubmitting(false);
64+
navigate("/", { replace: true });
65+
} catch {
66+
setPatError("Network error — please try again");
67+
setSubmitting(false);
68+
}
69+
}
70+
871
return (
972
<div class="bg-base-200 min-h-screen flex items-center justify-center">
1073
<div class="card bg-base-100 shadow-xl max-w-sm w-full mx-4">
1174
<div class="card-body items-center text-center gap-6">
12-
<div class="flex flex-col items-center gap-2">
13-
<h1 class="card-title text-2xl">
14-
GitHub Tracker
15-
</h1>
16-
<p class="text-sm text-base-content/60 text-center">
17-
Track issues, pull requests, and workflow runs across your GitHub
18-
repositories.
19-
</p>
20-
</div>
21-
22-
<button
23-
type="button"
24-
onClick={handleLogin}
25-
class="btn btn-neutral w-full"
75+
76+
<Show
77+
when={!showPatForm()}
78+
fallback={
79+
<form onSubmit={(e) => void handlePatSubmit(e)} class="w-full flex flex-col gap-4">
80+
<h2 class="card-title">Sign in with Token</h2>
81+
82+
<div class="text-left w-full">
83+
<label for="pat-input" class="label">
84+
<span class="label-text">Personal access token</span>
85+
</label>
86+
<input
87+
id="pat-input"
88+
type="password"
89+
autocomplete="new-password"
90+
placeholder="ghp_... or github_pat_..."
91+
class={`input input-bordered w-full${patError() !== null ? " input-error" : ""}`}
92+
aria-invalid={patError() !== null}
93+
aria-describedby={patError() !== null ? "pat-error" : undefined}
94+
value={patInput()}
95+
onInput={(e) => setPatInput(e.currentTarget.value)}
96+
/>
97+
<Show when={patError() !== null}>
98+
<p id="pat-error" role="alert" class="text-error text-xs mt-1">
99+
{patError()}
100+
</p>
101+
</Show>
102+
</div>
103+
104+
<button
105+
type="submit"
106+
class="btn btn-neutral w-full"
107+
disabled={submitting()}
108+
>
109+
{submitting() ? "Verifying..." : "Sign in"}
110+
</button>
111+
112+
<div class="text-left text-xs space-y-3 mt-4">
113+
<div>
114+
<p class="font-medium mb-1">
115+
<a
116+
href={GITHUB_PAT_URL}
117+
target="_blank"
118+
rel="noopener noreferrer"
119+
class="link link-primary"
120+
>
121+
Classic token
122+
</a>
123+
{" "}(fine for personal use) — select these scopes:
124+
</p>
125+
<ul class="list-disc list-inside space-y-0.5 text-base-content/70">
126+
<li><code>repo</code> — access repository data (issues, PRs, actions)</li>
127+
<li><code>read:org</code> — read organization membership</li>
128+
<li><code>notifications</code> — access notification status</li>
129+
</ul>
130+
</div>
131+
132+
<div>
133+
<p class="font-medium mb-1">
134+
<a
135+
href={GITHUB_FINE_GRAINED_PAT_URL}
136+
target="_blank"
137+
rel="noopener noreferrer"
138+
class="link link-primary"
139+
>
140+
Fine-grained token
141+
</a>
142+
{" "}(recommended by GitHub) — set repository access to "All repositories", then enable:
143+
</p>
144+
<ul class="list-disc list-inside space-y-0.5 text-base-content/70">
145+
{PAT_FINE_GRAINED_PERMISSIONS.repository.map((perm) => (
146+
<li>{perm}</li>
147+
))}
148+
</ul>
149+
</div>
150+
151+
<p class="text-warning text-xs">
152+
{PAT_FINE_GRAINED_NOTIFICATIONS_CAVEAT}
153+
</p>
154+
</div>
155+
156+
<button
157+
type="button"
158+
onClick={() => { setShowPatForm(false); setPatError(null); setPatInput(""); }}
159+
class="link link-primary text-sm mt-2"
160+
>
161+
Use OAuth instead
162+
</button>
163+
</form>
164+
}
26165
>
27-
<svg
28-
viewBox="0 0 16 16"
29-
class="w-5 h-5"
30-
aria-hidden="true"
31-
fill="currentColor"
166+
<div class="flex flex-col items-center gap-2">
167+
<h1 class="card-title text-2xl">
168+
GitHub Tracker
169+
</h1>
170+
<p class="text-sm text-base-content/60 text-center">
171+
Track issues, pull requests, and workflow runs across your GitHub
172+
repositories.
173+
</p>
174+
</div>
175+
176+
<button
177+
type="button"
178+
onClick={handleLogin}
179+
class="btn btn-neutral w-full"
32180
>
33-
<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" />
34-
</svg>
35-
Sign in with GitHub
36-
</button>
181+
<svg
182+
viewBox="0 0 16 16"
183+
class="w-5 h-5"
184+
aria-hidden="true"
185+
fill="currentColor"
186+
>
187+
<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" />
188+
</svg>
189+
Sign in with GitHub
190+
</button>
191+
192+
<div class="divider text-xs text-base-content/40">or</div>
193+
<button
194+
type="button"
195+
onClick={() => setShowPatForm(true)}
196+
class="link link-primary text-sm"
197+
>
198+
Use a Personal Access Token
199+
</button>
200+
</Show>
201+
37202
</div>
38203
</div>
39204
</div>

src/app/services/poll.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ async function hasNotificationChanges(): Promise<boolean> {
135135
(err as { status?: number }).status === 403
136136
) {
137137
console.warn("[poll] Notifications API returned 403 — disabling gate");
138-
pushNotification("notifications", "Notifications API returned 403 — check that the notifications scope is granted", "warning");
138+
pushNotification("notifications", config.authMethod === "pat"
139+
? "Notifications API returned 403 — fine-grained tokens may not support notifications"
140+
: "Notifications API returned 403 — check that the notifications scope is granted", "warning");
139141
_notifGateDisabled = true;
140142
}
141143
return true;

0 commit comments

Comments
 (0)