forked from cerc-io/snowballtools-base
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:
@@ -1 +1,3 @@
|
||||
REACT_APP_GQL_SERVER_URL = 'http://localhost:8000/graphql'
|
||||
|
||||
REACT_APP_GITHUB_CLIENT_ID = 4720362b6740b00652b6
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user