From 3841b8aec240cec43dd240acbbfb4699eea5fac5 Mon Sep 17 00:00:00 2001 From: Pepe Garcia Date: Thu, 11 Dec 2025 10:08:39 +0000 Subject: [PATCH] feat: add personal access token authentication --- src/api/api.ts | 54 +++++++++++ src/browser/components/welcome-dialog.tsx | 108 ++++++++++++++++++++++ src/browser/contexts/auth.tsx | 57 ++++++++++++ 3 files changed, 219 insertions(+) diff --git a/src/api/api.ts b/src/api/api.ts index 5b815a0..5c90066 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -91,6 +91,60 @@ const api = new Hono() } catch (err) { return c.json({ error: (err as Error).message }, 500); } + }) + + // Validate Personal Access Token + // Tests the token against GitHub API and returns user info and scope validation + .post("/auth/validate-token", async (c) => { + try { + const body = await c.req.json(); + const { token } = body; + + // Validate token format + if (!token || typeof token !== "string") { + return c.json({ valid: false, error: "Invalid token format" }, 400); + } + + // Test token against GitHub API + const response = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "Pulldash", + }, + }); + + if (!response.ok) { + const statusCode = response.status === 401 ? 401 : 500; + return c.json( + { + valid: false, + error: + response.status === 401 + ? "Invalid or expired token" + : "GitHub API error", + }, + statusCode + ); + } + + const userData = await response.json(); + + // Check token scopes (if available in headers) + const scopes = response.headers.get("x-oauth-scopes") || ""; + const hasRequiredScopes = scopes.includes("repo"); + + return c.json({ + valid: true, + user: userData.login, + hasRequiredScopes, + }); + } catch (err) { + return c.json( + { valid: false, error: "Validation failed" }, + 500 + ); + } }); export default api; diff --git a/src/browser/components/welcome-dialog.tsx b/src/browser/components/welcome-dialog.tsx index b18f35e..4b7e4b9 100644 --- a/src/browser/components/welcome-dialog.tsx +++ b/src/browser/components/welcome-dialog.tsx @@ -174,6 +174,112 @@ const reviewFiles = [ }, ]; +// ============================================================================ +// PAT Authentication Section +// ============================================================================ + +function PATAuthSection() { + const { loginWithPAT } = useAuth(); + const [showPATInput, setShowPATInput] = useState(false); + const [patToken, setPatToken] = useState(""); + const [patError, setPatError] = useState(null); + const [isValidatingPAT, setIsValidatingPAT] = useState(false); + + const handlePATLogin = async () => { + setPatError(null); + setIsValidatingPAT(true); + + try { + await loginWithPAT(patToken); + // Success - dialog should close automatically via auth context + } catch (error) { + setPatError(error instanceof Error ? error.message : "Authentication failed"); + } finally { + setIsValidatingPAT(false); + } + }; + + return ( +
+ + + {showPATInput && ( +
+
+ + setPatToken(e.target.value)} + placeholder="ghp_xxxxxxxxxxxx" + 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" + disabled={isValidatingPAT} + onKeyDown={(e) => { + if (e.key === "Enter" && patToken && !isValidatingPAT) { + handlePATLogin(); + } + }} + /> +
+ + {patError && ( +
+ {patError} +
+ )} + +
+

Required scopes:

+
    +
  • + repo - Access repositories +
  • +
  • + read:user - Read user profile +
  • +
+ + Create a token on GitHub + + +
+ + +
+ )} +
+ ); +} + // ============================================================================ // Stage 1: Live PR Updates Animation // ============================================================================ @@ -1101,6 +1207,8 @@ export function WelcomeDialog() { )} + +

All GitHub API calls are made directly from your device. Pulldash does not store your GitHub token. diff --git a/src/browser/contexts/auth.tsx b/src/browser/contexts/auth.tsx index 90e1872..08f3817 100644 --- a/src/browser/contexts/auth.tsx +++ b/src/browser/contexts/auth.tsx @@ -61,6 +61,7 @@ interface AuthState { interface AuthContextValue extends AuthState { startDeviceAuth: () => Promise; cancelDeviceAuth: () => void; + loginWithPAT: (token: string) => Promise; logout: () => void; // Enable anonymous browsing mode enableAnonymousMode: () => void; @@ -369,6 +370,61 @@ export function AuthProvider({ children }: { children: ReactNode }) { })); }, [abortController]); + const loginWithPAT = useCallback(async (token: string): Promise => { + // Basic format validation + const trimmedToken = token.trim(); + if (!trimmedToken) { + throw new Error("Token cannot be empty"); + } + + // Validate token format (GitHub PAT prefixes) + if ( + !trimmedToken.startsWith("ghp_") && + !trimmedToken.startsWith("github_pat_") + ) { + throw new Error( + 'Invalid token format. GitHub tokens should start with "ghp_" or "github_pat_"' + ); + } + + // Validate token with backend + const response = await fetch("/api/auth/validate-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: trimmedToken }), + }); + + const result = await response.json(); + + if (!result.valid) { + throw new Error(result.error || "Token validation failed"); + } + + // Warn if token doesn't have required scopes + if (!result.hasRequiredScopes) { + console.warn('Token may not have required "repo" scope'); + } + + // Store token (same mechanism as device flow) + storeToken(trimmedToken); + setStoredAnonymousMode(false); + setState({ + isAuthenticated: true, + isLoading: false, + token: trimmedToken, + deviceAuth: { + status: "idle", + userCode: null, + verificationUri: null, + error: null, + }, + isAnonymous: false, + isRateLimited: false, + }); + + console.log("Successfully authenticated with PAT as user:", result.user); + }, []); + const logout = useCallback(() => { clearStoredToken(); setStoredAnonymousMode(false); @@ -399,6 +455,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...state, startDeviceAuth, cancelDeviceAuth, + loginWithPAT, logout, enableAnonymousMode, canWrite: state.isAuthenticated && !state.isAnonymous,