diff --git a/src/browser/components/welcome-dialog.tsx b/src/browser/components/welcome-dialog.tsx index b18f35e..90c87e0 100644 --- a/src/browser/components/welcome-dialog.tsx +++ b/src/browser/components/welcome-dialog.tsx @@ -174,6 +174,127 @@ 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); + } catch (error) { + setPatError( + error instanceof Error ? error.message : "Authentication failed" + ); + } finally { + setIsValidatingPAT(false); + } + }; + + if (!showPATInput) { + return ( + + ); + } + + return ( +
+
+ setPatToken(e.target.value)} + placeholder="Paste your token (ghp_... or github_pat_...)" + className={cn( + "w-full h-10 px-3 rounded-md border bg-background text-foreground text-sm", + "placeholder:text-muted-foreground", + "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background", + "disabled:opacity-50 disabled:cursor-not-allowed", + patError && "border-destructive focus:ring-destructive" + )} + disabled={isValidatingPAT} + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter" && patToken && !isValidatingPAT) { + handlePATLogin(); + } + if (e.key === "Escape") { + setShowPATInput(false); + setPatToken(""); + setPatError(null); + } + }} + /> +
+ + {patError && ( +
+ + {patError} +
+ )} + +
+ + +
+ +

+ Requires{" "} + repo{" "} + scope.{" "} + + Create token → + +

+
+ ); +} + // ============================================================================ // Stage 1: Live PR Updates Animation // ============================================================================ @@ -1101,6 +1222,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..6338e59 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,69 @@ export function AuthProvider({ children }: { children: ReactNode }) { })); }, [abortController]); + const loginWithPAT = useCallback(async (token: string): Promise => { + 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 directly with GitHub API (CORS is supported) + const response = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${trimmedToken}`, + Accept: "application/vnd.github.v3+json", + }, + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error("Invalid or expired token"); + } + throw new Error("Failed to validate token with GitHub"); + } + + const userData = await response.json(); + + // Check token scopes from response headers + const scopes = response.headers.get("x-oauth-scopes") || ""; + const hasRepoScope = scopes.includes("repo"); + + if (!hasRepoScope) { + throw new Error( + 'Token is missing the required "repo" scope. Please create a new token with the 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:", userData.login); + }, []); + const logout = useCallback(() => { clearStoredToken(); setStoredAnonymousMode(false); @@ -399,6 +463,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...state, startDeviceAuth, cancelDeviceAuth, + loginWithPAT, logout, enableAnonymousMode, canWrite: state.isAuthenticated && !state.isAnonymous,