diff --git a/.gitignore b/.gitignore index cbfc9c0..6ff5fe4 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Local Netlify folder +.netlify diff --git a/README.md b/README.md index b0c369c..1ae9ccd 100644 --- a/README.md +++ b/README.md @@ -1 +1,32 @@ -# github-viewer \ No newline at end of file +# github-viewer + +## github app setup + +Follow [this guide](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) on how to create a GitHub App and add the homepage URL and callback URL according to your netlify app. + +**Homepage URL**: https://your-netlify-prefix.netlify.app/ + +**Callback URL**: https://your-netlify-prefix.app/callback + + +## netlify setup + +For this you can follow [this getting started page](https://docs.netlify.com/cli/get-started). + +But these three commands should be enough: + +```bash +npm install netlify-cli -g +netlify login +netlify init +``` + +After this add these env vars to netlify: + +**Any env var without the prefix VITE will not be available on the client side** + +``` +GITHUB_CLIENT_SECRET= +VITE_GITHUB_CLIENT_ID= +VITE_CODERS_INITIAL= // username1,displayName1|username2,displayName2|... +``` diff --git a/bun.lockb b/bun.lockb index 28072e0..cc46bc4 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..f8c4c08 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,9 @@ +[build] + command = "bun run build" + functions = "netlify/functions" + publish = "dist" + + [[redirects]] + from = "/*" + to = "/index.html" + status = 200 diff --git a/netlify/functions/exchange-token.ts b/netlify/functions/exchange-token.ts new file mode 100644 index 0000000..ce4f5ca --- /dev/null +++ b/netlify/functions/exchange-token.ts @@ -0,0 +1,51 @@ +import fetch from 'node-fetch'; +import { Handler, HandlerEvent } from '@netlify/functions'; + +const handler: Handler = async (event: HandlerEvent) => { + const client_id = process.env.VITE_GITHUB_CLIENT_ID!; + const client_secret = process.env.GITHUB_CLIENT_SECRET!; + const code = event.body ? JSON.parse(event.body).code : null; + + if (!code) { + return { + statusCode: 400, + body: JSON.stringify({ error: 'Code is required' }) + }; + } + + try { + const response = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + client_id, + client_secret, + code + }) + }); + + const data: any = await response.json(); + + if (data.error) { + return { + statusCode: 400, + body: JSON.stringify({ error: data.error }) + }; + } + + return { + statusCode: 200, + body: JSON.stringify(data) + }; + } catch (error) { + return { + statusCode: 500, + body: JSON.stringify({ error: 'Internal Server Error' }) + }; + } +}; + +export { handler }; diff --git a/netlify/functions/refresh-token.ts b/netlify/functions/refresh-token.ts new file mode 100644 index 0000000..e2059ba --- /dev/null +++ b/netlify/functions/refresh-token.ts @@ -0,0 +1,51 @@ +import fetch from 'node-fetch'; +import { Handler, HandlerEvent } from '@netlify/functions'; + +const handler: Handler = async (event: HandlerEvent) => { + const client_id = process.env.VITE_GITHUB_CLIENT_ID!; + const client_secret = process.env.GITHUB_CLIENT_SECRET!; + const refresh_token = event.body ? JSON.parse(event.body).refreshToken : null; + + if (!refresh_token) { + return { + statusCode: 400, + body: JSON.stringify({ error: 'refresh_token is required' }) + }; + } + + try { + const response = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + client_id, + client_secret, + refresh_token + }) + }); + + const data: any = await response.json(); + + if (data.error) { + return { + statusCode: 400, + body: JSON.stringify({ error: data.error }) + }; + } + + return { + statusCode: 200, + body: JSON.stringify(data) + }; + } catch (error) { + return { + statusCode: 500, + body: JSON.stringify({ error: 'Internal Server Error' }) + }; + } +}; + +export { handler }; diff --git a/package.json b/package.json index 403701f..79ce158 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,12 @@ "@mui/icons-material": "5.14.12", "@mui/joy": "5.0.0-beta.9", "@mui/material": "5.14.12", + "@netlify/functions": "2.7.0", "axios": "1.5.1", "graphql": "16.8.1", "graphql-request": "7.0.1", "immer": "10.0.3", + "node-fetch": "3.3.2", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.23.1", diff --git a/src/App.tsx b/src/App.tsx index ea33c9d..e622a3f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,34 @@ import { CssVarsProvider } from "@mui/joy/styles"; -import { Box, CssBaseline } from "@mui/joy"; +import { Box, Button, CssBaseline } from "@mui/joy"; import { PullRequestView } from "./pr"; +import { OpenInNew } from "@mui/icons-material"; +import { RouterProvider, createBrowserRouter } from "react-router-dom"; +import { AuthCallback } from "./AuthCallback"; + +const router = createBrowserRouter([ + { + path: "/", + element: , + }, + { + path: "/login", + element: ( + + ), + }, + { + path: "/callback", + element: , + }, +]); export function App() { return ( @@ -21,7 +49,7 @@ export function App() { overflowY: "hidden", }} > - + diff --git a/src/AuthCallback.tsx b/src/AuthCallback.tsx new file mode 100644 index 0000000..8bd5353 --- /dev/null +++ b/src/AuthCallback.tsx @@ -0,0 +1,52 @@ +import { Box, Typography } from "@mui/joy"; +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { storeToken } from "./utils"; + +export const AuthCallback = () => { + const navigate = useNavigate(); + const [urlSearchParams] = useSearchParams(); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState([]); + + useEffect(() => { + if (urlSearchParams.get("code") != null) { + setLoading(true); + fetch("/.netlify/functions/exchange-token", { + method: "POST", + body: JSON.stringify({ code: urlSearchParams.get("code") }), + }) + .then((response) => response.json()) + .then((data) => { + setLoading(false); + storeToken(data) + navigate("/"); + }) + .catch((error) => { + setLoading(false); + setErrors([error.message]) + }); + } else { + setErrors(["No code found in URL."]) + } + + }, [urlSearchParams]); + + return ( + + + {loading && errors.length === 0 ? "Loading..." : null} + {!loading && errors.length !== 0 ? errors.map(error => {error}) : null} + + + ); +}; diff --git a/src/api/api.ts b/src/api/api.ts index f1e33dc..8b93e34 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,5 +1,11 @@ import { GithubGraphqlClient } from "./GithubGraphqlClient.ts"; +import { getAccessToken } from "../utils"; -const AUTH_TOKEN = import.meta.env.VITE_AUTH_TOKEN; +let api: GithubGraphqlClient; -export const api = new GithubGraphqlClient(AUTH_TOKEN); +export async function getApi() { + if (!api) { + api = new GithubGraphqlClient(await getAccessToken()); + } + return api; +} diff --git a/src/api/fetchAllOpenPrs.ts b/src/api/fetchAllOpenPrs.ts index e32633a..9807109 100644 --- a/src/api/fetchAllOpenPrs.ts +++ b/src/api/fetchAllOpenPrs.ts @@ -1,6 +1,6 @@ +import { getApi } from "./api.ts"; import { mapResult } from "./mapResult.ts"; import { PullRequest } from "./types.ts"; -import { api } from "./api.ts"; export async function fetchAllOpenPrs( usernames: string[], @@ -11,6 +11,7 @@ export async function fetchAllOpenPrs( const openPRs: PullRequest[] = []; while (hasNextPage) { + const api = await getApi(); const response = await api.getPullRequests(usernames, "open", 50, after); const mappedResult = mapResult(response, baseUsername); diff --git a/src/utils/authHelper.ts b/src/utils/authHelper.ts new file mode 100644 index 0000000..fa0fa0a --- /dev/null +++ b/src/utils/authHelper.ts @@ -0,0 +1,47 @@ +interface TokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + refresh_token_expires_in: number; +} + +export function storeToken({ + access_token, + refresh_token, + expires_in, + refresh_token_expires_in +}: TokenResponse) { + localStorage.setItem('github_access_token', access_token); + localStorage.setItem('github_refresh_token', refresh_token); + localStorage.setItem('github_token_expiry', (Date.now() + expires_in * 1000).toString()); + localStorage.setItem('github_refresh_token_expiry', (Date.now() + refresh_token_expires_in * 1000).toString()); +} + +async function refreshAccessToken() { + const refreshToken = localStorage.getItem('github_refresh_token'); + if (!refreshToken) { + throw new Error('No refresh token found'); + } + fetch("/.netlify/functions/refresh-token", { + method: "POST", + body: JSON.stringify({ refreshToken}), + }) + .then((response) => response.json()) + .then((data) => { + storeToken(data) + }) +} + +export async function getAccessToken() { + const token = localStorage.getItem('github_access_token'); + const expiry = localStorage.getItem('github_token_expiry'); + if (!token || !expiry) { + throw new Error('No token found'); + } + + if (parseInt(expiry) < Date.now()) { + await refreshAccessToken(); + } + + return token ?? ''; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index fd871d4..605b24a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,2 @@ export * from "./timeAgo.ts"; +export * from "./authHelper.ts";