diff --git a/nextstep-backend/src/app.ts b/nextstep-backend/src/app.ts index 371440b..fb7f02a 100644 --- a/nextstep-backend/src/app.ts +++ b/nextstep-backend/src/app.ts @@ -15,6 +15,7 @@ import validateUser from "./middleware/validateUser"; import loadOpenApiFile from "./openapi/openapi_loader"; import resource_routes from './routes/resources_routes'; import resume_routes from './routes/resume_routes'; +import githubRoutes from './routes/github_routes'; const specs = swaggerJsdoc(options); @@ -74,5 +75,6 @@ app.use('/user', usersRoutes); app.use('/resource', resource_routes); app.use('/room', roomsRoutes); app.use('/resume', resume_routes); +app.use('/github', githubRoutes); export { app, corsOptions }; diff --git a/nextstep-backend/src/controllers/github_controller.ts b/nextstep-backend/src/controllers/github_controller.ts new file mode 100644 index 0000000..1df6563 --- /dev/null +++ b/nextstep-backend/src/controllers/github_controller.ts @@ -0,0 +1,93 @@ +import axios from 'axios'; +import { Request, Response } from 'express'; + +const clientId = process.env.GITHUB_CLIENT_ID; +const clientSecret = process.env.GITHUB_CLIENT_SECRET; + +export const handleGitHubOAuth = async (req: Request, res: Response) => { + const { code } = req.body; + + if (!code) { + return res.status(400).json({ error: 'Authorization code is required' }); + } + + try { + const tokenResponse = await axios.post('https://github.com/login/oauth/access_token', { + headers: { 'Content-Type': 'application/json' }, + client_id: clientId, + client_secret: clientSecret, + code: code, + }) as any; + + const tokenData = await tokenResponse.data; + const params = new URLSearchParams(tokenData); + const accessToken = params.get('access_token'); + + if (!accessToken) { + return res.status(400).json({ error: 'Failed to retrieve access token' }); + } + + const userResponse = await axios.get('https://api.github.com/user', { + headers: { Authorization: `Bearer ${accessToken}` }, + }) as {status: number, data: { login?: string }, json: () => Promise}; + + if (userResponse.status !== 200) { + const errorText = await userResponse.data; + return res.status(400).json({ error: errorText }); + } + + const userData = await userResponse.data; + res.json({ username: userData.login }); + } catch (error) { + console.error('Error during GitHub OAuth:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +export const fetchGitHubRepos = async (req: Request, res: Response) => { + const { username } = req.params; + const { accessToken } = req.query; // Optional access token for authenticated requests + + try { + const apiUrl = `https://api.github.com/users/${username}/repos`; + + const headers = accessToken + ? { Authorization: `Bearer ${accessToken}` } + : undefined; + + const response = await axios.get(apiUrl, { headers }) as { data: any, status: number }; + + if (response.status !== 200) { + return res.status(400).json({ error: `Error fetching repos: ${response.status}` }); + } + + const repos = await response.data; + res.json(repos); + } catch (error) { + console.error('Error fetching repos:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +export const fetchRepoLanguages = async (req: Request, res: Response) => { + const { repoUrl } = req.query; + + if (!repoUrl) { + return res.status(400).json({ error: 'Repository URL is required' }); + } + + try { + // Convert the repoUrl to the GitHub API URL if necessary + const apiUrl = (repoUrl as string).replace('https://github.com/', 'https://api.github.com/repos/'); + const response = await axios.get(`${apiUrl}/languages`); + + if (response.status !== 200) { + return res.status(400).json({ error: `Error fetching languages: ${response.statusText}` }); + } + + res.json(response.data); + } catch (error) { + console.error('Error fetching languages:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; diff --git a/nextstep-backend/src/routes/github_routes.ts b/nextstep-backend/src/routes/github_routes.ts new file mode 100644 index 0000000..5969109 --- /dev/null +++ b/nextstep-backend/src/routes/github_routes.ts @@ -0,0 +1,10 @@ +import express from 'express'; +import { handleGitHubOAuth, fetchGitHubRepos, fetchRepoLanguages } from '../controllers/github_controller'; + +const router = express.Router(); + +router.post('/oauth', handleGitHubOAuth); +router.get('/repos/:username', fetchGitHubRepos); +router.get('/languages', fetchRepoLanguages); + +export default router; diff --git a/nextstep-frontend/src/App.tsx b/nextstep-frontend/src/App.tsx index 4eb7bc9..75e65d3 100644 --- a/nextstep-frontend/src/App.tsx +++ b/nextstep-frontend/src/App.tsx @@ -13,6 +13,7 @@ import Chat from './pages/Chat'; import Resume from './pages/Resume'; import TopBar from './components/TopBar'; import Layout from './components/Layout'; +import MainDashboard from './pages/MainDashboard'; const App: React.FC = () => { return ( @@ -28,6 +29,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> diff --git a/nextstep-frontend/src/components/Footer.css b/nextstep-frontend/src/components/Footer.css index 404eb5f..16fe36d 100644 --- a/nextstep-frontend/src/components/Footer.css +++ b/nextstep-frontend/src/components/Footer.css @@ -1,5 +1,5 @@ .footer { - background-color: var(--color-5); + background-color: #233752; color: var(--color-2); padding: 10px 20px; text-align: center; diff --git a/nextstep-frontend/src/components/TopBar.tsx b/nextstep-frontend/src/components/TopBar.tsx index 2a3350d..a23a6fe 100644 --- a/nextstep-frontend/src/components/TopBar.tsx +++ b/nextstep-frontend/src/components/TopBar.tsx @@ -20,10 +20,10 @@ const TopBar: React.FC = () => { }; return ( - + - navigate('/dashboard')} sx={{ mx: 1 }}> + navigate('/main-dashboard')} sx={{ mx: 1 }}> diff --git a/nextstep-frontend/src/handlers/githubAuth.ts b/nextstep-frontend/src/handlers/githubAuth.ts new file mode 100644 index 0000000..5961d59 --- /dev/null +++ b/nextstep-frontend/src/handlers/githubAuth.ts @@ -0,0 +1,72 @@ +import api from "../serverApi.ts"; + +// Function to fetch GitHub repositories by username +export const fetchGitHubRepos = async (username: string) => { + try { + const response = await api.get(`github/repos/${username}`) as any; + if (response.status !== 200) { + throw new Error(`Error fetching repos: ${response.statusText}`); + } + return response.data; + } catch (error) { + console.error(error); + return []; + } +}; + +// Function to connect to GitHub by username +export const connectToGitHub = async (username: string) => { + if (!username) { + throw new Error('Username is required to connect to GitHub.'); + } + return await fetchGitHubRepos(username); +}; + +// Function to initiate GitHub OAuth login (redirect only) +export const initiateGitHubOAuth = () => { + const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID; // Use environment variable + const redirectUri = import.meta.env.VITE_GITHUB_REDIRECT_URI; // Use environment variable + const scope = 'public_repo'; // Only read access to repositories + const authUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}`; + window.location.href = authUrl; // Redirect the user to GitHub's authorization page +}; + +// Function to handle GitHub OAuth token exchange and fetching user details +export const handleGitHubOAuth = async (code: string) => { + try { + const response = await api.post('github/oauth', { + headers: { 'Content-Type': 'application/json' }, + code: code, + }) as {data: {username?: string, error?: string}, status: number}; + + if (response.status !== 200) { + const errorText = await response.data.error; + console.error('Backend OAuth Error:', errorText); + throw new Error('Failed to authenticate with GitHub'); + } + else if (!response.data.username) { + console.error('Backend OAuth Error:', response.data.error); + throw new Error('Failed to retrieve user information from GitHub'); + } + + return response.data.username; + } catch (error) { + const err : Error = error as Error; + console.error('Error during GitHub OAuth:', error); + throw new Error('Internal server error: ' + err.message); + } +}; + +// Function to fetch languages for a specific repository +export const fetchRepoLanguages = async (repoUrl: string) => { + try { + const response = await api.get(`github/languages?repoUrl=${encodeURIComponent(repoUrl)}`) as any; + if (response.status !== 200) { + throw new Error(`Error fetching languages: ${response.statusText}`); + } + return await response.data; + } catch (error) { + console.error(error); + return {}; + } +}; \ No newline at end of file diff --git a/nextstep-frontend/src/pages/Login.tsx b/nextstep-frontend/src/pages/Login.tsx index fec6550..741691f 100644 --- a/nextstep-frontend/src/pages/Login.tsx +++ b/nextstep-frontend/src/pages/Login.tsx @@ -32,7 +32,7 @@ const Login: React.FC = () => { setUserAuth(res.data); - navigate("/dashboard"); + navigate("/main-dashboard"); } catch (error) { console.error("Google login failed:", error); setError("Google login failed."); @@ -51,7 +51,7 @@ const Login: React.FC = () => { // Handle successful login, e.g., save tokens, redirect, etc. setUserAuth(response.data) - navigate('/dashboard'); // Redirect to dashboard or another page after login + navigate('/main-dashboard'); // Redirect to dashboard or another page after login } catch (error) { // Handle login error diff --git a/nextstep-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx new file mode 100644 index 0000000..1d25426 --- /dev/null +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -0,0 +1,290 @@ +import React, { useState, useEffect } from 'react'; +import { + Container, + Grid, + Box, + Typography, + TextField, + Button, + Chip, + Stack, + Avatar, + Divider, + Autocomplete, + FormControl, + InputLabel, + Select, + MenuItem +} from '@mui/material'; +import { + GitHub, + LinkedIn, + Person as PersonIcon, + Work as WorkIcon, + Build as BuildIcon +} from '@mui/icons-material'; +import { + connectToGitHub, + initiateGitHubOAuth, + fetchRepoLanguages, + handleGitHubOAuth +} from '../handlers/githubAuth'; + +const roles = [ + 'Software Engineer', 'Frontend Developer', 'Backend Developer', + 'Full Stack Developer', 'DevOps Engineer', 'Product Manager', 'UI/UX Designer' +]; + +const skillsList = [ + 'React', 'JavaScript', 'TypeScript', 'Python', 'Java', 'Node.js', + 'Express', 'MongoDB', 'AWS', 'Docker', 'Kubernetes', 'Git', 'Agile' +]; + +const MainDashboard: React.FC = () => { + const [aboutMe, setAboutMe] = useState(() => localStorage.getItem('aboutMe') || ''); + const [skills, setSkills] = useState(() => JSON.parse(localStorage.getItem('skills') || '[]')); + const [newSkill, setNewSkill] = useState(''); + const [selectedRole, setSelectedRole] = useState(() => localStorage.getItem('selectedRole') || ''); + const [repos, setRepos] = useState<{ id: number; name: string; html_url: string }[]>([]); + const [useOAuth, setUseOAuth] = useState(true); + const [showAuthOptions, setShowAuthOptions] = useState(false); + + // NEW: control how many skills to show before toggling + const [showAllSkills, setShowAllSkills] = useState(false); + const SKILL_DISPLAY_LIMIT = 6; + const shouldShowToggle = skills.length > SKILL_DISPLAY_LIMIT; + + useEffect(() => { localStorage.setItem('aboutMe', aboutMe); }, [aboutMe]); + useEffect(() => { localStorage.setItem('skills', JSON.stringify(skills)); }, [skills]); + useEffect(() => { localStorage.setItem('selectedRole', selectedRole); }, [selectedRole]); + + useEffect(() => { + const code = new URLSearchParams(window.location.search).get('code'); + if (code) { + (async () => { + try { + const username = await handleGitHubOAuth(code); + const fetched = await connectToGitHub(username); + setRepos(fetched); + mergeRepoLanguages(fetched); + } catch (e) { + console.error(e); + } + })(); + } + }, []); + + const mergeRepoLanguages = async (fetchedRepos: typeof repos) => { + const langSet = new Set(skills); + for (const repo of fetchedRepos) { + const langs = await fetchRepoLanguages(repo.html_url); + Object.keys(langs).forEach(lang => langSet.add(lang)); + } + setSkills(Array.from(langSet)); + }; + + const handleAddSkill = (skill: string) => { + const trimmed = skill.trim(); + if (!trimmed || skills.includes(trimmed)) return; + setSkills(prev => [trimmed, ...prev]); + setNewSkill(''); + }; + + const handleDeleteSkill = (skillToDelete: string) => { + setSkills(prev => prev.filter(s => s !== skillToDelete)); + }; + + const handleGitHubConnect = async () => { + if (!showAuthOptions) return setShowAuthOptions(true); + try { + if (useOAuth) initiateGitHubOAuth(); + else { + const username = prompt('Enter GitHub username:'); + if (!username) return alert('Username required'); + const fetched = await connectToGitHub(username); + setRepos(fetched); + mergeRepoLanguages(fetched); + } + } catch (e) { + console.error(e); + } finally { + setShowAuthOptions(false); + } + }; + + return ( + + + {/* Left Side */} + + + {/* About Me Section */} + + + + + About Me + + + setAboutMe(e.target.value)} + /> + + + {/* Desired Role Section */} + + + + + Desired Role + + + setSelectedRole(val)} + renderInput={params => ( + + )} + /> + + + {/* Skills Section */} + + + + + Skills + + + + + {/* Show only up to the limit when collapsed */} + + {(showAllSkills ? skills : skills.slice(0, SKILL_DISPLAY_LIMIT)).map(skill => ( + handleDeleteSkill(skill)} + /> + ))} + + + {/* Render toggle button only if there are extra skills */} + {shouldShowToggle && ( + + )} + + {/* Add New Skill */} + + setNewSkill(val)} + onChange={(_, val) => val && handleAddSkill(val)} + renderInput={params => ( + e.key === 'Enter' && handleAddSkill(newSkill)} + /> + )} + sx={{ flexGrow: 1 }} + /> + + + + + + + {/* Right Side */} + + + + + Connect Accounts + + + {showAuthOptions ? ( + + + Method + + + + + + ) : ( + + + + + )} + + {repos.length > 0 && ( + + + Repositories: + + + {repos.map(repo => ( + + ))} + + + )} + + + + + ); +}; + +export default MainDashboard;