Implement Github authentication to show repositories list (#45)

* Use react-oauth-popup for github authentication popup

* Fetch auth token and use in app to fetch list of repositories

* Get client id and secret from config

* Use GitHub search API for fetching repos

* Use debounce for searching repos and projects
This commit is contained in:
2024-02-01 11:37:57 +05:30
committed by Ashwin Phatak
parent 2f8d21baf5
commit e1e9a7063e
21 changed files with 647 additions and 86 deletions
+2
View File
@@ -1 +1,3 @@
REACT_APP_GQL_SERVER_URL = 'http://localhost:8000/graphql'
REACT_APP_GITHUB_CLIENT_ID = 4720362b6740b00652b6
+3
View File
@@ -17,17 +17,20 @@
"eslint-config-react-app": "^7.0.1",
"gql-client": "^1.0.0",
"luxon": "^3.4.4",
"octokit": "^3.1.2",
"react": "^18.2.0",
"react-day-picker": "^8.9.1",
"react-dom": "^18.2.0",
"react-dropdown": "^1.11.0",
"react-hook-form": "^7.49.0",
"react-hot-toast": "^2.4.1",
"react-oauth-popup": "^1.0.5",
"react-router-dom": "^6.20.1",
"react-scripts": "5.0.1",
"react-tabs": "^6.0.2",
"react-timer-hook": "^3.0.7",
"typescript": "^4.9.5",
"usehooks-ts": "^2.10.0",
"vertical-stepper-nav": "^1.0.2",
"web-vitals": "^2.1.4"
},
@@ -1,6 +1,7 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useCombobox } from 'downshift';
import { Project } from 'gql-client';
import { useDebounce } from 'usehooks-ts';
import {
List,
@@ -30,12 +31,6 @@ const ProjectSearchBar = ({ onChange }: ProjectsSearchProps) => {
highlightedIndex,
inputValue,
} = useCombobox({
onInputValueChange({ inputValue }) {
if (inputValue) {
// TODO: Use debounce
fetchProjects(inputValue);
}
},
items,
itemToString(item) {
return item ? item.name : '';
@@ -52,6 +47,8 @@ const ProjectSearchBar = ({ onChange }: ProjectsSearchProps) => {
},
});
const debouncedInputValue = useDebounce<string>(inputValue, 500);
const fetchProjects = useCallback(
async (inputValue: string) => {
const { searchProjects } = await client.searchProjects(inputValue);
@@ -60,6 +57,12 @@ const ProjectSearchBar = ({ onChange }: ProjectsSearchProps) => {
[client],
);
useEffect(() => {
if (debouncedInputValue) {
fetchProjects(debouncedInputValue);
}
}, [fetchProjects, debouncedInputValue]);
return (
<div className="relative">
<SearchBar {...getInputProps()} />
@@ -1,6 +1,29 @@
import { Button } from '@material-tailwind/react';
import React from 'react';
import OauthPopup from 'react-oauth-popup';
import { useGQLClient } from '../../../context/GQLClientContext';
const SCOPES = 'repo user';
const GITHUB_OAUTH_URL = `https://github.com/login/oauth/authorize?client_id=${
process.env.REACT_APP_GITHUB_CLIENT_ID
}&scope=${encodeURIComponent(SCOPES)}`;
interface ConnectAccountInterface {
onToken: (token: string) => void;
}
const ConnectAccount = ({ onToken }: ConnectAccountInterface) => {
const client = useGQLClient();
const handleCode = async (code: string) => {
// Pass code to backend and get access token
const {
authenticateGithub: { token },
} = await client.authenticateGithub(code);
onToken(token);
};
const ConnectAccount = () => {
return (
<div className="bg-gray-100 flex flex-col p-4 justify-end items-center text-center text-sm h-60 rounded-2xl">
<div>^</div>
@@ -11,13 +34,18 @@ const ConnectAccount = () => {
under the account
</p>
</div>
<div>
<button className="bg-gray-300 rounded-full mx-2">
Connect to Github
</button>
<button className="bg-gray-300 rounded-full mx-2">
Connect to Gitea
</button>
<div className="mt-2 flex">
<OauthPopup
url={GITHUB_OAUTH_URL}
onCode={handleCode}
onClose={() => {}}
title="Snowball"
width={1000}
height={1000}
>
<Button className="rounded-full mx-2">Connect to Github</Button>
</OauthPopup>
<Button className="rounded-full mx-2">Connect to Gitea</Button>
</div>
</div>
);
@@ -3,10 +3,10 @@ import React from 'react';
import { Chip, IconButton } from '@material-tailwind/react';
import { relativeTime } from '../../../utils/time';
import { RepositoryDetails } from '../../../types/project';
import { GitRepositoryDetails } from '../../../types/project';
interface ProjectRepoCardProps {
repository: RepositoryDetails;
repository: GitRepositoryDetails;
onClick: () => void;
}
@@ -22,10 +22,8 @@ const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({
<div>^</div>
<div className="grow">
<div>
<span className="text-black">
{repository.user}/{repository.title}
</span>
{repository.private ? (
<span className="text-black">{repository.full_name}</span>
{repository.visibility === 'private' ? (
<Chip
className="normal-case inline ml-6 bg-[#FED7AA] text-[#EA580C] font-normal"
size="sm"
@@ -36,7 +34,7 @@ const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({
''
)}
</div>
<p>{relativeTime(repository.updatedAt)}</p>
<p>{repository.updated_at && relativeTime(repository.updated_at)}</p>
</div>
<div className="hidden group-hover:block">
<IconButton size="sm">{'>'}</IconButton>
@@ -1,61 +1,133 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Octokit } from 'octokit';
import assert from 'assert';
import { useDebounce } from 'usehooks-ts';
import { Button, Typography, Option, Select } from '@material-tailwind/react';
import SearchBar from '../../SearchBar';
import ProjectRepoCard from './ProjectRepoCard';
import repositoryDetails from '../../../assets/repositories.json';
import { RepositoryDetails } from '../../../types/project';
import { GitOrgDetails, GitRepositoryDetails } from '../../../types/project';
const DEFAULT_SEARCHED_REPO = '';
const DEFAULT_SELECTED_USER = 'All accounts';
const REPOS_PER_PAGE = 5;
interface RepositoryListProps {
repoSelectionHandler: (repo: RepositoryDetails) => void;
repoSelectionHandler: (repo: GitRepositoryDetails) => void;
token: string;
}
const RepositoryList = ({ repoSelectionHandler }: RepositoryListProps) => {
const RepositoryList = ({
repoSelectionHandler,
token,
}: RepositoryListProps) => {
const [searchedRepo, setSearchedRepo] = useState(DEFAULT_SEARCHED_REPO);
const [selectedUser, setSelectedUser] = useState(DEFAULT_SELECTED_USER);
const [selectedAccount, setSelectedAccount] = useState('');
const [orgs, setOrgs] = useState<GitOrgDetails[]>([]);
// TODO: Add new type for Git user when required
const [gitUser, setGitUser] = useState<GitOrgDetails>();
const filteredRepos = useMemo(() => {
return repositoryDetails.filter((repo) => {
const titleMatch =
!searchedRepo ||
repo.title.toLowerCase().includes(searchedRepo.toLowerCase());
const userMatch =
selectedUser === DEFAULT_SELECTED_USER || selectedUser === repo.user;
return titleMatch && userMatch;
});
}, [searchedRepo, selectedUser]);
const [repositoryDetails, setRepositoryDetails] = useState<
GitRepositoryDetails[]
>([]);
const octokit = useMemo(() => {
// TODO: Create github/octokit context
return new Octokit({ auth: token });
}, [token]);
useEffect(() => {
const fetchUserAndOrgs = async () => {
const user = await octokit.rest.users.getAuthenticated();
const orgs = await octokit.rest.orgs.listForAuthenticatedUser();
setOrgs(orgs.data);
setGitUser(user.data);
setSelectedAccount(user.data.login);
};
if (token) {
fetchUserAndOrgs();
}
}, [octokit]);
const debouncedSearchedRepo = useDebounce<string>(searchedRepo, 500);
useEffect(() => {
const fetchRepos = async () => {
if (!selectedAccount || !gitUser) {
return;
}
// Check search input and use GitHub search API
if (debouncedSearchedRepo) {
let query = `${debouncedSearchedRepo} in:name fork:true`;
// Check if selected account is an organization
if (selectedAccount === gitUser.login) {
query = query + ` user:${selectedAccount}`;
} else {
query = query + ` org:${selectedAccount}`;
}
const result = await octokit.rest.search.repos({
q: query,
per_page: REPOS_PER_PAGE,
});
setRepositoryDetails(result.data.items);
return;
}
if (selectedAccount === gitUser.login) {
const result = await octokit.rest.repos.listForAuthenticatedUser({
per_page: REPOS_PER_PAGE,
affiliation: 'owner',
});
setRepositoryDetails(result.data);
return;
}
const selectedOrg = orgs.find((org) => org.login === selectedAccount);
assert(selectedOrg, 'Selected org not found in list');
const result = await octokit.rest.repos.listForOrg({
org: selectedOrg.login,
per_page: REPOS_PER_PAGE,
type: 'all',
});
setRepositoryDetails(result.data);
};
fetchRepos();
}, [selectedAccount, gitUser, orgs, debouncedSearchedRepo]);
const handleResetFilters = useCallback(() => {
assert(gitUser, 'Git user is not available');
setSearchedRepo(DEFAULT_SEARCHED_REPO);
setSelectedUser(DEFAULT_SELECTED_USER);
}, []);
setSelectedAccount(gitUser.login);
}, [gitUser]);
const users = useMemo(() => {
return [
DEFAULT_SELECTED_USER,
...Array.from(new Set(repositoryDetails.map((repo) => repo.user))),
];
}, []);
const accounts = useMemo(() => {
if (!octokit || !gitUser) {
return [];
}
return [gitUser, ...orgs];
}, [octokit, orgs, gitUser]);
return (
<div className="p-4">
<div className="flex gap-2 mb-2">
<div className="basis-1/3">
{/* TODO: Fix selection of Git user at start */}
<Select
value={selectedUser}
onChange={(value) => setSelectedUser(value!)}
value={selectedAccount}
onChange={(value) => setSelectedAccount(value!)}
>
{users.map((user, key) => (
<Option
className={user === selectedUser ? 'hidden' : ''}
key={key}
value={user}
>
^ {user}
{accounts.map((account) => (
<Option key={account.id} value={account.login}>
^ {account.login}
</Option>
))}
</Select>
@@ -68,8 +140,8 @@ const RepositoryList = ({ repoSelectionHandler }: RepositoryListProps) => {
/>
</div>
</div>
{Boolean(filteredRepos.length) ? (
filteredRepos.map((repo, key) => {
{Boolean(repositoryDetails.length) ? (
repositoryDetails.map((repo, key) => {
return (
<ProjectRepoCard
repository={repo}
@@ -7,19 +7,19 @@ import { Button, Input, Switch, Typography } from '@material-tailwind/react';
import RepositoryList from '../../create/RepositoryList';
import RepoConnectedSection from './RepoConnectedSection';
import GitSelectionSection from './GitSelectionSection';
import { GitSelect, RepositoryDetails } from '../../../../types/project';
import { GitRepositoryDetails, GitSelect } from '../../../../types/project';
import WebhookCard from './WebhookCard';
const GitTabPanel = () => {
const [gitSelect, setGitSelect] = useState('none');
const [linkedRepo, setLinkedRepo] = useState<RepositoryDetails>();
const [linkedRepo, setLinkedRepo] = useState<GitRepositoryDetails>();
const [webhooksArray, setWebhooksArray] = useState<Array<string>>([]);
const gitSelectionHandler = (git: GitSelect) => {
setGitSelect(git);
};
const repoSelectionHandler = (repoDetails: RepositoryDetails) => {
const repoSelectionHandler = (repoDetails: GitRepositoryDetails) => {
setLinkedRepo(repoDetails);
};
@@ -54,7 +54,11 @@ const GitTabPanel = () => {
(GitSelect.NONE === gitSelect ? (
<GitSelectionSection gitSelectionHandler={gitSelectionHandler} />
) : (
<RepositoryList repoSelectionHandler={repoSelectionHandler} />
<RepositoryList
repoSelectionHandler={repoSelectionHandler}
// TODO: Pass Github access token after authentication
token=""
/>
))}
<div className="flex justify-between mt-4">
@@ -2,13 +2,13 @@ import React, { useState } from 'react';
import { Button, Typography } from '@material-tailwind/react';
import { RepositoryDetails } from '../../../../types/project';
import { GitRepositoryDetails } from '../../../../types/project';
import ConfirmDialog from '../../../shared/ConfirmDialog';
const RepoConnectedSection = ({
linkedRepo,
}: {
linkedRepo: RepositoryDetails;
linkedRepo: GitRepositoryDetails;
}) => {
const [disconnectRepoDialogOpen, setDisconnectRepoDialogOpen] =
useState(false);
@@ -17,9 +17,7 @@ const RepoConnectedSection = ({
<div className="flex gap-4">
<div>^</div>
<div className="grow">
<Typography variant="small">
{linkedRepo.user}/{linkedRepo.title}
</Typography>
<Typography variant="small">{linkedRepo.full_name}</Typography>
<Typography variant="small">Connected just now</Typography>
</div>
<div>
@@ -1,13 +1,20 @@
import React from 'react';
import React, { useCallback, useState } from 'react';
import templateDetails from '../../../assets/templates.json';
import TemplateCard from '../../../components/projects/create/TemplateCard';
import RepositoryList from '../../../components/projects/create/RepositoryList';
import ConnectAccount from '../../../components/projects/create/ConnectAccount';
const IS_GIT_AUTH = true;
const NewProject = () => {
const [isGitAuth, setIsGitAuth] = useState(false);
const [gitToken, setGitToken] = useState('');
// TODO: Get DB user details for checking if already authenticated to Github
const handleToken = useCallback((token: string) => {
setGitToken(token);
setIsGitAuth(true);
}, []);
return (
<>
<h5 className="mt-4 ml-4">Start with template</h5>
@@ -17,10 +24,10 @@ const NewProject = () => {
})}
</div>
<h5 className="mt-4 ml-4">Import a repository</h5>
{IS_GIT_AUTH ? (
<RepositoryList repoSelectionHandler={() => {}} />
{isGitAuth ? (
<RepositoryList token={gitToken} repoSelectionHandler={() => {}} />
) : (
<ConnectAccount />
<ConnectAccount onToken={handleToken} />
)}
</>
);
+16
View File
@@ -25,6 +25,13 @@ export enum Status {
ERROR = 'Error',
}
export interface GitOrgDetails {
id: number;
login: string;
avatar_url: string;
}
// TODO: Use GitRepositoryDetails
export interface RepositoryDetails {
title: string;
updatedAt: string;
@@ -33,6 +40,15 @@ export interface RepositoryDetails {
branch: string[];
}
export interface GitRepositoryDetails {
id: number;
name: string;
full_name: string;
owner: GitOrgDetails | null;
visibility?: string;
updated_at?: string | null;
}
export enum GitSelect {
GITHUB = 'github',
GITEA = 'gitea',