app/components/AuthHeader.tsx (360 lines of code) (raw):
import React, { useState, useEffect, useRef } from "react";
import { AuthService, AuthStatus } from "~/lib/authService";
import { useTheme } from "~/lib/theme";
import { ApiKeyModal } from "~/components/ApiKeyModal";
interface AuthHeaderProps {
authStatus: AuthStatus;
onLogout: () => void;
userInfo?: {
username: string;
fullName: string;
avatarUrl: string;
} | null;
}
export const AuthHeader: React.FC<AuthHeaderProps> = ({
authStatus,
onLogout,
userInfo: externalUserInfo,
}) => {
const { theme, setTheme, resolvedTheme } = useTheme();
const [userInfo, setUserInfo] = useState<{
username: string;
fullName: string;
avatarUrl: string;
} | null>(externalUserInfo || null);
const [githubOAuth2Available, setGithubOAuth2Available] = useState(false);
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false);
const prevAuthStatusRef = useRef(authStatus);
// Update user info when external userInfo changes or auth status changes
useEffect(() => {
// Check GitHub OAuth2 availability
AuthService.isGitHubOAuth2Available().then(setGithubOAuth2Available);
const prevAuthStatus = prevAuthStatusRef.current;
const hasChanged =
prevAuthStatus.isAuthenticated !== authStatus.isAuthenticated ||
prevAuthStatus.hasHuggingFace !== authStatus.hasHuggingFace;
if (hasChanged) {
console.log("AuthHeader: Auth status actually changed:", {
from: {
isAuthenticated: prevAuthStatus.isAuthenticated,
hasHuggingFace: prevAuthStatus.hasHuggingFace,
},
to: {
isAuthenticated: authStatus.isAuthenticated,
hasHuggingFace: authStatus.hasHuggingFace,
},
});
}
prevAuthStatusRef.current = authStatus;
// If external userInfo is provided, use it
if (externalUserInfo) {
setUserInfo(externalUserInfo);
return;
}
// If auth status has user info, use it directly
if (authStatus.hfUserInfo) {
console.log(
"AuthHeader: Using user info from auth status:",
authStatus.hfUserInfo
);
setUserInfo(authStatus.hfUserInfo);
return;
}
// Only fetch if authenticated, otherwise clear user info
if (!authStatus.isAuthenticated) {
setUserInfo(null);
return;
}
// Fallback: Use the async getCredentials method if external userInfo is not provided
const fetchUserInfo = async () => {
try {
const credentials = await AuthService.getCredentials();
if (credentials?.hfUserInfo) {
console.log(
"AuthHeader: Setting user info from credentials:",
credentials.hfUserInfo
);
setUserInfo(credentials.hfUserInfo);
} else {
console.log("AuthHeader: No user info found in credentials");
setUserInfo(null);
}
} catch (error) {
console.error("Error fetching credentials:", error);
setUserInfo(null);
}
};
fetchUserInfo();
}, [
authStatus.isAuthenticated,
authStatus.hasHuggingFace,
authStatus.hfUserInfo,
externalUserInfo,
]); // Include externalUserInfo and authStatus.hfUserInfo in dependencies
// // Also check immediately on mount and periodically
// useEffect(() => {
// const checkUserInfo = async () => {
// try {
// const credentials = await AuthService.getCredentials();
// if (credentials?.hfUserInfo && !userInfo) {
// console.log(
// "AuthHeader: Found user info on check:",
// credentials.hfUserInfo
// );
// setUserInfo(credentials.hfUserInfo);
// }
// } catch (error) {
// console.error("Error in periodic user info check:", error);
// }
// };
// // Check immediately
// checkUserInfo();
// // Check every 2 seconds for the first 10 seconds after mount
// // This helps catch cases where the cookie takes time to be set
// const interval = setInterval(checkUserInfo, 2000);
// setTimeout(() => clearInterval(interval), 10000);
// return () => clearInterval(interval);
// }, [userInfo]);
const handleLogout = async () => {
await AuthService.logout();
setUserInfo(null);
onLogout();
};
const formatExpiryTime = (expiresAt?: Date) => {
if (!expiresAt) return "";
const now = new Date();
const diff = expiresAt.getTime() - now.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h`;
return "< 1h";
};
const displayName = userInfo?.username || userInfo?.fullName || "User";
return (
<div className="border-b border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full">
<img
src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg"
alt="Logo"
className="w-7 rounded-full md:mr-2"
/>
</div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Hugex
</h1>
</div>
<div className="flex items-center gap-4">
{/* Theme Toggle */}
<div className="flex items-center gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-700">
<button
onClick={() => setTheme("light")}
className={`rounded p-1.5 text-xs transition-colors ${
theme === "light"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-600 dark:text-gray-100"
: "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
}`}
title="Light mode"
>
<i className="fas fa-sun"></i>
</button>
<button
onClick={() => setTheme("dark")}
className={`rounded p-1.5 text-xs transition-colors ${
theme === "dark"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-600 dark:text-gray-100"
: "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
}`}
title="Dark mode"
>
<i className="fas fa-moon"></i>
</button>
<button
onClick={() => setTheme("system")}
className={`rounded p-1.5 text-xs transition-colors ${
theme === "system"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-600 dark:text-gray-100"
: "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
}`}
title="System preference"
>
<i className="fas fa-desktop"></i>
</button>
</div>
{/* User Menu */}
<div className="group relative">
<button className="flex items-center gap-2 text-sm text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">
<span className="max-w-32 truncate" title={displayName}>
{displayName}
</span>
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-gray-100">
{userInfo?.avatarUrl ? (
<img
src={userInfo.avatarUrl}
alt={displayName}
className="h-6 w-6 rounded-full"
onError={(e) => {
// Fallback to checkmark if avatar fails to load
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
<svg
className="h-3 w-3 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</div>
<svg
className="h-4 w-4 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{/* Dropdown Menu */}
<div className="invisible absolute right-0 top-full z-50 mt-2 w-64 rounded-lg border border-gray-200 bg-white opacity-0 shadow-lg transition-all duration-200 group-hover:visible group-hover:opacity-100 dark:border-gray-700 dark:bg-gray-800">
<div className="space-y-3 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Session Info
</div>
<div className="space-y-2">
{userInfo && (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
User:
</span>
<span className="max-w-32 truncate font-medium text-gray-900 dark:text-gray-100">
{displayName}
</span>
</div>
{userInfo.fullName &&
userInfo.fullName !== userInfo.username && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
Name:
</span>
<span className="max-w-32 truncate text-gray-900 dark:text-gray-100">
{userInfo.fullName}
</span>
</div>
)}
</>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
Status:
</span>
<span className="font-medium text-green-600 dark:text-green-400">
Active
</span>
</div>
{authStatus.expiresAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
Expires in:
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{formatExpiryTime(authStatus.expiresAt)}
</span>
</div>
)}
{/* GitHub Connection Status */}
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
GitHub:
</span>
<div className="flex items-center gap-2">
{authStatus.hasGitHub ? (
<>
<span className="font-medium text-green-600 dark:text-green-400">
Connected
</span>
{authStatus.githubUserInfo && (
<span className="text-xs text-gray-500 dark:text-gray-400">
@{authStatus.githubUserInfo.username}
</span>
)}
</>
) : (
<span className="text-gray-500 dark:text-gray-400">
Not connected
</span>
)}
</div>
</div>
</div>
<div className="border-t border-gray-200 pt-3 dark:border-gray-600">
{/* GitHub Account Actions */}
{githubOAuth2Available && (
<>
{authStatus.hasGitHub ? (
<button
onClick={() => {
// For now, just show that it's connected
// In a full implementation, you might want to add a disconnect option
alert(
"GitHub account is connected. Private repositories are now accessible."
);
}}
className="flex w-full items-center rounded px-2 py-2 text-left text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100"
>
<svg
className="mr-2 h-4 w-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
clipRule="evenodd"
/>
</svg>
GitHub Connected
</button>
) : (
<button
onClick={() => AuthService.startGitHubOAuth2Login()}
className="flex w-full items-center rounded px-2 py-2 text-left text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100"
>
<svg
className="mr-2 h-4 w-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
clipRule="evenodd"
/>
</svg>
Connect GitHub
</button>
)}
<div className="px-2 py-1">
<p className="text-xs text-gray-500 dark:text-gray-400">
{authStatus.hasGitHub
? "Access private repositories for coding tasks"
: "Connect to access private repositories"}
</p>
</div>
</>
)}
{/* API Key Management */}
<button
onClick={() => setIsApiKeyModalOpen(true)}
className="flex w-full items-center rounded px-2 py-2 text-left text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100"
>
<i className="fas fa-key mr-2 h-4 w-4"></i>
API Key
</button>
{/* <button
onClick={() => AuthService.extendSession()}
className="w-full text-left text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 py-2 px-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center"
>
<i className="fas fa-sync-alt mr-2"></i>Extend Session
</button> */}
<button
onClick={handleLogout}
className="flex w-full items-center rounded px-2 py-2 text-left text-sm text-red-600 transition-colors hover:bg-red-50 hover:text-red-800 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:text-red-300"
>
<i className="fas fa-sign-out-alt mr-2"></i>Sign Out
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* API Key Modal */}
<ApiKeyModal
isOpen={isApiKeyModalOpen}
onClose={() => setIsApiKeyModalOpen(false)}
/>
</div>
);
};