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
54 changes: 54 additions & 0 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
108 changes: 108 additions & 0 deletions src/browser/components/welcome-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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 (
<div className="border-t pt-4">
<button
onClick={() => setShowPATInput(!showPATInput)}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{showPATInput ? "← Use OAuth instead" : "Or sign in with Personal Access Token →"}
</button>

{showPATInput && (
<div className="mt-4 space-y-3">
<div>
<label htmlFor="pat-token" className="block text-sm font-medium mb-2">
GitHub Personal Access Token
</label>
<input
id="pat-token"
type="password"
value={patToken}
onChange={(e) => 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();
}
}}
/>
</div>

{patError && (
<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">
{patError}
</div>
)}

<div className="text-xs text-muted-foreground space-y-2">
<p className="font-medium">Required scopes:</p>
<ul className="list-disc list-inside space-y-1 ml-1">
<li>
<code className="bg-muted px-1 py-0.5 rounded">repo</code> - Access repositories
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">read:user</code> - Read user profile
</li>
</ul>
<a
href="https://github.com/settings/tokens/new?scopes=repo,read:user&description=Pulldash"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline mt-2"
>
Create a token on GitHub
<ExternalLink className="w-3 h-3" />
</a>
</div>

<Button
onClick={handlePATLogin}
disabled={!patToken || isValidatingPAT}
className="w-full h-10 gap-2"
>
{isValidatingPAT ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Validating...
</>
) : (
<>
<Github className="w-4 h-4" />
Sign in with PAT
</>
)}
</Button>
</div>
)}
</div>
);
}

// ============================================================================
// Stage 1: Live PR Updates Animation
// ============================================================================
Expand Down Expand Up @@ -1101,6 +1207,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
57 changes: 57 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,61 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}));
}, [abortController]);

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