Skip to content
Open
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
123 changes: 123 additions & 0 deletions src/browser/components/welcome-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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 (
<button
onClick={() => setShowPATInput(true)}
className="w-full text-center text-sm text-muted-foreground hover:text-foreground transition-colors py-2"
>
Or use a Personal Access Token
</button>
);
}

return (
<div className="space-y-3 pt-2">
<div className="relative">
<input
id="pat-token"
type="password"
value={patToken}
onChange={(e) => 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);
}
}}
/>
</div>

{patError && (
<div className="flex items-start gap-2 p-2.5 rounded-md bg-destructive/10 border border-destructive/20 text-destructive text-sm">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{patError}</span>
</div>
)}

<div className="flex gap-2">
<Button
onClick={handlePATLogin}
disabled={!patToken || isValidatingPAT}
className="flex-1 h-9 gap-2"
>
{isValidatingPAT ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Validating...
</>
) : (
"Sign in"
)}
</Button>
<Button
variant="ghost"
onClick={() => {
setShowPATInput(false);
setPatToken("");
setPatError(null);
}}
className="h-9 px-3 text-muted-foreground"
disabled={isValidatingPAT}
>
Cancel
</Button>
</div>

<p className="text-xs text-muted-foreground">
Requires{" "}
<code className="px-1 py-0.5 rounded bg-muted font-mono">repo</code>{" "}
scope.{" "}
<a
href="https://github.com/settings/tokens/new?scopes=repo,read:user&description=Pulldash"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
Create token →
</a>
</p>
</div>
);
}

// ============================================================================
// Stage 1: Live PR Updates Animation
// ============================================================================
Expand Down Expand Up @@ -1101,6 +1222,8 @@ export function WelcomeDialog() {
)}
</Button>

<PATAuthSection />

<p className="text-xs text-center text-muted-foreground">
All GitHub API calls are made directly from your device.
Pulldash does not store your GitHub token.
Expand Down
65 changes: 65 additions & 0 deletions src/browser/contexts/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ interface AuthState {
interface AuthContextValue extends AuthState {
startDeviceAuth: () => Promise<void>;
cancelDeviceAuth: () => void;
loginWithPAT: (token: string) => Promise<void>;
logout: () => void;
// Enable anonymous browsing mode
enableAnonymousMode: () => void;
Expand Down Expand Up @@ -369,6 +370,69 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}));
}, [abortController]);

const loginWithPAT = useCallback(async (token: string): Promise<void> => {
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);
Expand Down Expand Up @@ -399,6 +463,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
...state,
startDeviceAuth,
cancelDeviceAuth,
loginWithPAT,
logout,
enableAnonymousMode,
canWrite: state.isAuthenticated && !state.isAnonymous,
Expand Down