Skip to content

Commit b4cd3d2

Browse files
committed
refactor: move PAT validation to client-side
- Remove /api/auth/validate-token server endpoint - Validate PAT directly against GitHub API from client (CORS supported) - Enforce repo scope requirement with clear error message - Improve PAT input UI with cleaner layout and better UX - Add escape key to cancel, autofocus input field
1 parent 3841b8a commit b4cd3d2

File tree

3 files changed

+108
-139
lines changed

3 files changed

+108
-139
lines changed

src/api/api.ts

Lines changed: 0 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -91,60 +91,6 @@ const api = new Hono()
9191
} catch (err) {
9292
return c.json({ error: (err as Error).message }, 500);
9393
}
94-
})
95-
96-
// Validate Personal Access Token
97-
// Tests the token against GitHub API and returns user info and scope validation
98-
.post("/auth/validate-token", async (c) => {
99-
try {
100-
const body = await c.req.json();
101-
const { token } = body;
102-
103-
// Validate token format
104-
if (!token || typeof token !== "string") {
105-
return c.json({ valid: false, error: "Invalid token format" }, 400);
106-
}
107-
108-
// Test token against GitHub API
109-
const response = await fetch("https://api.github.com/user", {
110-
headers: {
111-
Authorization: `Bearer ${token}`,
112-
Accept: "application/vnd.github.v3+json",
113-
"User-Agent": "Pulldash",
114-
},
115-
});
116-
117-
if (!response.ok) {
118-
const statusCode = response.status === 401 ? 401 : 500;
119-
return c.json(
120-
{
121-
valid: false,
122-
error:
123-
response.status === 401
124-
? "Invalid or expired token"
125-
: "GitHub API error",
126-
},
127-
statusCode
128-
);
129-
}
130-
131-
const userData = await response.json();
132-
133-
// Check token scopes (if available in headers)
134-
const scopes = response.headers.get("x-oauth-scopes") || "";
135-
const hasRequiredScopes = scopes.includes("repo");
136-
137-
return c.json({
138-
valid: true,
139-
user: userData.login,
140-
hasRequiredScopes,
141-
});
142-
} catch (err) {
143-
return c.json(
144-
{ valid: false, error: "Validation failed" },
145-
500
146-
);
147-
}
14894
});
14995

15096
export default api;

src/browser/components/welcome-dialog.tsx

Lines changed: 86 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -191,91 +191,106 @@ function PATAuthSection() {
191191

192192
try {
193193
await loginWithPAT(patToken);
194-
// Success - dialog should close automatically via auth context
195194
} catch (error) {
196-
setPatError(error instanceof Error ? error.message : "Authentication failed");
195+
setPatError(
196+
error instanceof Error ? error.message : "Authentication failed"
197+
);
197198
} finally {
198199
setIsValidatingPAT(false);
199200
}
200201
};
201202

202-
return (
203-
<div className="border-t pt-4">
203+
if (!showPATInput) {
204+
return (
204205
<button
205-
onClick={() => setShowPATInput(!showPATInput)}
206-
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
206+
onClick={() => setShowPATInput(true)}
207+
className="w-full text-center text-sm text-muted-foreground hover:text-foreground transition-colors py-2"
207208
>
208-
{showPATInput ? "← Use OAuth instead" : "Or sign in with Personal Access Token →"}
209+
Or use a Personal Access Token
209210
</button>
211+
);
212+
}
210213

211-
{showPATInput && (
212-
<div className="mt-4 space-y-3">
213-
<div>
214-
<label htmlFor="pat-token" className="block text-sm font-medium mb-2">
215-
GitHub Personal Access Token
216-
</label>
217-
<input
218-
id="pat-token"
219-
type="password"
220-
value={patToken}
221-
onChange={(e) => setPatToken(e.target.value)}
222-
placeholder="ghp_xxxxxxxxxxxx"
223-
className="w-full px-3 py-2 border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500"
224-
disabled={isValidatingPAT}
225-
onKeyDown={(e) => {
226-
if (e.key === "Enter" && patToken && !isValidatingPAT) {
227-
handlePATLogin();
228-
}
229-
}}
230-
/>
231-
</div>
232-
233-
{patError && (
234-
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-3 rounded border border-red-200 dark:border-red-900">
235-
{patError}
236-
</div>
214+
return (
215+
<div className="space-y-3 pt-2">
216+
<div className="relative">
217+
<input
218+
id="pat-token"
219+
type="password"
220+
value={patToken}
221+
onChange={(e) => setPatToken(e.target.value)}
222+
placeholder="Paste your token (ghp_... or github_pat_...)"
223+
className={cn(
224+
"w-full h-10 px-3 rounded-md border bg-background text-foreground text-sm",
225+
"placeholder:text-muted-foreground",
226+
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background",
227+
"disabled:opacity-50 disabled:cursor-not-allowed",
228+
patError && "border-destructive focus:ring-destructive"
237229
)}
230+
disabled={isValidatingPAT}
231+
autoFocus
232+
onKeyDown={(e) => {
233+
if (e.key === "Enter" && patToken && !isValidatingPAT) {
234+
handlePATLogin();
235+
}
236+
if (e.key === "Escape") {
237+
setShowPATInput(false);
238+
setPatToken("");
239+
setPatError(null);
240+
}
241+
}}
242+
/>
243+
</div>
238244

239-
<div className="text-xs text-muted-foreground space-y-2">
240-
<p className="font-medium">Required scopes:</p>
241-
<ul className="list-disc list-inside space-y-1 ml-1">
242-
<li>
243-
<code className="bg-muted px-1 py-0.5 rounded">repo</code> - Access repositories
244-
</li>
245-
<li>
246-
<code className="bg-muted px-1 py-0.5 rounded">read:user</code> - Read user profile
247-
</li>
248-
</ul>
249-
<a
250-
href="https://github.com/settings/tokens/new?scopes=repo,read:user&description=Pulldash"
251-
target="_blank"
252-
rel="noopener noreferrer"
253-
className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline mt-2"
254-
>
255-
Create a token on GitHub
256-
<ExternalLink className="w-3 h-3" />
257-
</a>
258-
</div>
259-
260-
<Button
261-
onClick={handlePATLogin}
262-
disabled={!patToken || isValidatingPAT}
263-
className="w-full h-10 gap-2"
264-
>
265-
{isValidatingPAT ? (
266-
<>
267-
<Loader2 className="w-4 h-4 animate-spin" />
268-
Validating...
269-
</>
270-
) : (
271-
<>
272-
<Github className="w-4 h-4" />
273-
Sign in with PAT
274-
</>
275-
)}
276-
</Button>
245+
{patError && (
246+
<div className="flex items-start gap-2 p-2.5 rounded-md bg-destructive/10 border border-destructive/20 text-destructive text-sm">
247+
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
248+
<span>{patError}</span>
277249
</div>
278250
)}
251+
252+
<div className="flex gap-2">
253+
<Button
254+
onClick={handlePATLogin}
255+
disabled={!patToken || isValidatingPAT}
256+
className="flex-1 h-9 gap-2"
257+
>
258+
{isValidatingPAT ? (
259+
<>
260+
<Loader2 className="w-4 h-4 animate-spin" />
261+
Validating...
262+
</>
263+
) : (
264+
"Sign in"
265+
)}
266+
</Button>
267+
<Button
268+
variant="ghost"
269+
onClick={() => {
270+
setShowPATInput(false);
271+
setPatToken("");
272+
setPatError(null);
273+
}}
274+
className="h-9 px-3 text-muted-foreground"
275+
disabled={isValidatingPAT}
276+
>
277+
Cancel
278+
</Button>
279+
</div>
280+
281+
<p className="text-xs text-muted-foreground">
282+
Requires{" "}
283+
<code className="px-1 py-0.5 rounded bg-muted font-mono">repo</code>{" "}
284+
scope.{" "}
285+
<a
286+
href="https://github.com/settings/tokens/new?scopes=repo,read:user&description=Pulldash"
287+
target="_blank"
288+
rel="noopener noreferrer"
289+
className="text-foreground hover:underline"
290+
>
291+
Create token →
292+
</a>
293+
</p>
279294
</div>
280295
);
281296
}

src/browser/contexts/auth.tsx

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
371371
}, [abortController]);
372372

373373
const loginWithPAT = useCallback(async (token: string): Promise<void> => {
374-
// Basic format validation
375374
const trimmedToken = token.trim();
376375
if (!trimmedToken) {
377376
throw new Error("Token cannot be empty");
@@ -387,22 +386,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
387386
);
388387
}
389388

390-
// Validate token with backend
391-
const response = await fetch("/api/auth/validate-token", {
392-
method: "POST",
393-
headers: { "Content-Type": "application/json" },
394-
body: JSON.stringify({ token: trimmedToken }),
389+
// Validate token directly with GitHub API (CORS is supported)
390+
const response = await fetch("https://api.github.com/user", {
391+
headers: {
392+
Authorization: `Bearer ${trimmedToken}`,
393+
Accept: "application/vnd.github.v3+json",
394+
},
395395
});
396396

397-
const result = await response.json();
398-
399-
if (!result.valid) {
400-
throw new Error(result.error || "Token validation failed");
397+
if (!response.ok) {
398+
if (response.status === 401) {
399+
throw new Error("Invalid or expired token");
400+
}
401+
throw new Error("Failed to validate token with GitHub");
401402
}
402403

403-
// Warn if token doesn't have required scopes
404-
if (!result.hasRequiredScopes) {
405-
console.warn('Token may not have required "repo" scope');
404+
const userData = await response.json();
405+
406+
// Check token scopes from response headers
407+
const scopes = response.headers.get("x-oauth-scopes") || "";
408+
const hasRepoScope = scopes.includes("repo");
409+
410+
if (!hasRepoScope) {
411+
throw new Error(
412+
'Token is missing the required "repo" scope. Please create a new token with the repo scope.'
413+
);
406414
}
407415

408416
// Store token (same mechanism as device flow)
@@ -422,7 +430,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
422430
isRateLimited: false,
423431
});
424432

425-
console.log("Successfully authenticated with PAT as user:", result.user);
433+
console.log("Successfully authenticated with PAT as:", userData.login);
426434
}, []);
427435

428436
const logout = useCallback(() => {

0 commit comments

Comments
 (0)