Save gitHub token in DB and unauthenticate on expiry (#58)

This commit is contained in:
Nabarun Gogoi 2024-01-31 18:51:53 +05:30 committed by Ashwin Phatak
parent 0dd6c7702a
commit 3a7f16467e
13 changed files with 155 additions and 76 deletions

View File

@ -1,6 +1,7 @@
import { DataSource, DeepPartial } from 'typeorm'; import { DataSource, DeepPartial } from 'typeorm';
import path from 'path'; import path from 'path';
import debug from 'debug'; import debug from 'debug';
import assert from 'assert';
import { DatabaseConfig } from './config'; import { DatabaseConfig } from './config';
import { User } from './entity/User'; import { User } from './entity/User';
@ -14,6 +15,7 @@ import { Domain } from './entity/Domain';
const log = debug('snowball:database'); const log = debug('snowball:database');
// TODO: Fix order of methods
export class Database { export class Database {
private dataSource: DataSource; private dataSource: DataSource;
@ -41,6 +43,14 @@ export class Database {
return user; return user;
} }
async updateUser (userId: number, data: DeepPartial<User>): Promise<boolean> {
const userRepository = this.dataSource.getRepository(User);
const updateResult = await userRepository.update({ id: Number(userId) }, data);
assert(updateResult.affected);
return updateResult.affected > 0;
}
async getOrganizationsByUserId (userId: number): Promise<Organization[]> { async getOrganizationsByUserId (userId: number): Promise<Organization[]> {
const userOrganizationRepository = this.dataSource.getRepository(UserOrganization); const userOrganizationRepository = this.dataSource.getRepository(UserOrganization);

View File

@ -18,6 +18,9 @@ export class User {
@Column() @Column()
email!: string; email!: string;
@Column('varchar', { nullable: true })
gitHubToken!: string | null;
@CreateDateColumn() @CreateDateColumn()
createdAt!: Date; createdAt!: Date;

View File

@ -245,15 +245,24 @@ export const createResolvers = async (db: Database, app: OAuthApp): Promise<any>
} }
}, },
authenticateGithub: async (_: any, { code }: { code: string }) => { authenticateGitHub: async (_: any, { code }: { code: string }, context: any) => {
// TOO: Move to Service class // TOO: Move to Service class
const { authentication: { token } } = await app.createToken({ const { authentication: { token } } = await app.createToken({
code code
}); });
// TODO: Save bearer token in DB for user await db.updateUser(context.userId, { gitHubToken: token });
return { token }; return { token };
},
unauthenticateGitHub: async (_: any, __: object, context: any) => {
try {
return db.updateUser(context.userId, { gitHubToken: null });
} catch (err) {
log(err);
return false;
}
} }
} }
}; };

View File

@ -34,6 +34,7 @@ type User {
projects: [Project!] projects: [Project!]
createdAt: String! createdAt: String!
updatedAt: String! updatedAt: String!
gitHubToken: String
} }
type Organization { type Organization {
@ -143,7 +144,8 @@ type Mutation {
rollbackDeployment(projectId: String!, deploymentId: String!): Boolean! rollbackDeployment(projectId: String!, deploymentId: String!): Boolean!
addDomain(projectId: String!, domainDetails: AddDomainInput!): Boolean! addDomain(projectId: String!, domainDetails: AddDomainInput!): Boolean!
updateDomain(domainId: String!, domainDetails: UpdateDomainInput!): Boolean! updateDomain(domainId: String!, domainDetails: UpdateDomainInput!): Boolean!
authenticateGithub(code: String!): AuthResult! authenticateGitHub(code: String!): AuthResult!
unauthenticateGitHub: Boolean!
} }
input AddEnvironmentVariableInput { input AddEnvironmentVariableInput {

View File

@ -19,8 +19,8 @@ const ConnectAccount = ({ onToken }: ConnectAccountInterface) => {
const handleCode = async (code: string) => { const handleCode = async (code: string) => {
// Pass code to backend and get access token // Pass code to backend and get access token
const { const {
authenticateGithub: { token }, authenticateGitHub: { token },
} = await client.authenticateGithub(code); } = await client.authenticateGitHub(code);
onToken(token); onToken(token);
}; };

View File

@ -14,12 +14,12 @@ const REPOS_PER_PAGE = 5;
interface RepositoryListProps { interface RepositoryListProps {
repoSelectionHandler: (repo: GitRepositoryDetails) => void; repoSelectionHandler: (repo: GitRepositoryDetails) => void;
token: string; octokit: Octokit;
} }
const RepositoryList = ({ const RepositoryList = ({
repoSelectionHandler, repoSelectionHandler,
token, octokit,
}: RepositoryListProps) => { }: RepositoryListProps) => {
const [searchedRepo, setSearchedRepo] = useState(DEFAULT_SEARCHED_REPO); const [searchedRepo, setSearchedRepo] = useState(DEFAULT_SEARCHED_REPO);
const [selectedAccount, setSelectedAccount] = useState(''); const [selectedAccount, setSelectedAccount] = useState('');
@ -31,11 +31,6 @@ const RepositoryList = ({
GitRepositoryDetails[] GitRepositoryDetails[]
>([]); >([]);
const octokit = useMemo(() => {
// TODO: Create github/octokit context
return new Octokit({ auth: token });
}, [token]);
useEffect(() => { useEffect(() => {
const fetchUserAndOrgs = async () => { const fetchUserAndOrgs = async () => {
const user = await octokit.rest.users.getAuthenticated(); const user = await octokit.rest.users.getAuthenticated();
@ -45,9 +40,7 @@ const RepositoryList = ({
setSelectedAccount(user.data.login); setSelectedAccount(user.data.login);
}; };
if (token) { fetchUserAndOrgs();
fetchUserAndOrgs();
}
}, [octokit]); }, [octokit]);
const debouncedSearchedRepo = useDebounce<string>(searchedRepo, 500); const debouncedSearchedRepo = useDebounce<string>(searchedRepo, 500);

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import toast from 'react-hot-toast';
import { IconButton, Typography } from '@material-tailwind/react'; import { IconButton, Typography } from '@material-tailwind/react';
@ -10,25 +11,40 @@ interface TemplateDetails {
} }
interface TemplateCardProps { interface TemplateCardProps {
framework: TemplateDetails; framework: TemplateDetails;
isGitAuth: boolean;
} }
const TemplateCard: React.FC<TemplateCardProps> = ({ framework }) => { const CardDetails = ({ framework }: { framework: TemplateDetails }) => {
return ( return (
<Link to={'/projects/create/template'}> <div className="h-14 group bg-gray-200 border-gray-200 rounded-lg shadow p-4 flex items-center justify-between">
<div className="h-14 group bg-gray-200 border-gray-200 rounded-lg shadow p-4 flex items-center justify-between"> <Typography className="grow">
<Typography className="grow"> {framework.icon} {framework.framework}
{framework.icon} {framework.framework} </Typography>
</Typography> <div>
<div> <IconButton size="sm" className="rounded-full hidden group-hover:block">
<IconButton {'>'}
size="sm" </IconButton>
className="rounded-full hidden group-hover:block"
>
{'>'}
</IconButton>
</div>
</div> </div>
</div>
);
};
const TemplateCard: React.FC<TemplateCardProps> = ({
framework,
isGitAuth,
}) => {
return isGitAuth ? (
<Link to="/projects/create/template">
<CardDetails framework={framework} />
</Link> </Link>
) : (
<a
onClick={() =>
toast.error('Connect Git account to start with a template')
}
>
<CardDetails framework={framework} />
</a>
); );
}; };

View File

@ -4,25 +4,14 @@ import toast from 'react-hot-toast';
import { Button, Input, Switch, Typography } from '@material-tailwind/react'; import { Button, Input, Switch, Typography } from '@material-tailwind/react';
import RepositoryList from '../../create/RepositoryList'; import { GitRepositoryDetails } from '../../../../types/project';
import RepoConnectedSection from './RepoConnectedSection';
import GitSelectionSection from './GitSelectionSection';
import { GitRepositoryDetails, GitSelect } from '../../../../types/project';
import WebhookCard from './WebhookCard'; import WebhookCard from './WebhookCard';
const GitTabPanel = () => { const GitTabPanel = () => {
const [gitSelect, setGitSelect] = useState('none'); // TODO: Get linked repo from project
const [linkedRepo, setLinkedRepo] = useState<GitRepositoryDetails>(); const [linkedRepo] = useState<GitRepositoryDetails>();
const [webhooksArray, setWebhooksArray] = useState<Array<string>>([]); const [webhooksArray, setWebhooksArray] = useState<Array<string>>([]);
const gitSelectionHandler = (git: GitSelect) => {
setGitSelect(git);
};
const repoSelectionHandler = (repoDetails: GitRepositoryDetails) => {
setLinkedRepo(repoDetails);
};
const { const {
register, register,
handleSubmit, handleSubmit,
@ -44,22 +33,8 @@ const GitTabPanel = () => {
<> <>
<div className="mb-2 p-2"> <div className="mb-2 p-2">
<Typography variant="h6" className="text-black"> <Typography variant="h6" className="text-black">
Connect Git repository Git repository
</Typography> </Typography>
<Typography variant="small">
Create deployments for any commits pushed to your Git repository.
</Typography>
{linkedRepo && <RepoConnectedSection linkedRepo={linkedRepo} />}
{!linkedRepo &&
(GitSelect.NONE === gitSelect ? (
<GitSelectionSection gitSelectionHandler={gitSelectionHandler} />
) : (
<RepositoryList
repoSelectionHandler={repoSelectionHandler}
// TODO: Pass Github access token after authentication
token=""
/>
))}
<div className="flex justify-between mt-4"> <div className="flex justify-between mt-4">
<div> <div>

View File

@ -1,31 +1,83 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { User } from 'gql-client';
import { Octokit, RequestError } from 'octokit';
import templateDetails from '../../../assets/templates.json'; import templateDetails from '../../../assets/templates.json';
import TemplateCard from '../../../components/projects/create/TemplateCard'; import TemplateCard from '../../../components/projects/create/TemplateCard';
import RepositoryList from '../../../components/projects/create/RepositoryList'; import RepositoryList from '../../../components/projects/create/RepositoryList';
import ConnectAccount from '../../../components/projects/create/ConnectAccount'; import ConnectAccount from '../../../components/projects/create/ConnectAccount';
import { useGQLClient } from '../../../context/GQLClientContext';
const UNAUTHORIZED_ERROR_CODE = 401;
const NewProject = () => { const NewProject = () => {
const [isGitAuth, setIsGitAuth] = useState(false); const client = useGQLClient();
const [gitToken, setGitToken] = useState(''); const [user, setUser] = useState<User>();
// TODO: Get DB user details for checking if already authenticated to Github
const handleToken = useCallback((token: string) => { const octokit = useMemo(() => {
setGitToken(token); if (!user?.gitHubToken) {
setIsGitAuth(true); return;
}
// TODO: Create github/octokit context
return new Octokit({ auth: user.gitHubToken });
}, [user]);
const fetchUser = useCallback(async () => {
const { user } = await client.getUser();
setUser(user);
}, []); }, []);
const handleToken = useCallback(() => {
fetchUser();
}, []);
useEffect(() => {
fetchUser();
}, []);
useEffect(() => {
if (!octokit) {
return;
}
const interceptor = async (error: RequestError | Error) => {
if (
error instanceof RequestError &&
error.status === UNAUTHORIZED_ERROR_CODE
) {
await client.unauthenticateGithub();
await fetchUser();
}
throw error;
};
octokit.hook.error('request', interceptor);
return () => {
// Remove the interceptor when the component unmounts
octokit.hook.remove('request', interceptor);
};
}, [octokit, client]);
return ( return (
<> <>
<h5 className="mt-4 ml-4">Start with template</h5> <h5 className="mt-4 ml-4">Start with template</h5>
<div className="grid grid-cols-3 p-4 gap-4"> <div className="grid grid-cols-3 p-4 gap-4">
{templateDetails.map((framework, key) => { {templateDetails.map((framework, key) => {
return <TemplateCard framework={framework} key={key} />; return (
<TemplateCard
isGitAuth={Boolean(user?.gitHubToken)}
framework={framework}
key={key}
/>
);
})} })}
</div> </div>
<h5 className="mt-4 ml-4">Import a repository</h5> <h5 className="mt-4 ml-4">Import a repository</h5>
{isGitAuth ? ( {Boolean(octokit) ? (
<RepositoryList token={gitToken} repoSelectionHandler={() => {}} /> <RepositoryList octokit={octokit!} repoSelectionHandler={() => {}} />
) : ( ) : (
<ConnectAccount onToken={handleToken} /> <ConnectAccount onToken={handleToken} />
)} )}

View File

@ -1,8 +1,8 @@
import { ApolloClient, DefaultOptions, InMemoryCache, NormalizedCacheObject } from '@apollo/client'; import { ApolloClient, DefaultOptions, InMemoryCache, NormalizedCacheObject } from '@apollo/client';
import { getUser, getOrganizations, getDeployments, getProjectMembers, searchProjects, getEnvironmentVariables, getProject, getDomains, getProjectsInOrganization } from './queries'; import { getUser, getOrganizations, getDeployments, getProjectMembers, searchProjects, getEnvironmentVariables, getProject, getDomains, getProjectsInOrganization } from './queries';
import { AddEnvironmentVariableInput, AddEnvironmentVariablesResponse, GetDeploymentsResponse, GetEnvironmentVariablesResponse, GetOrganizationsResponse, GetProjectMembersResponse, SearchProjectsResponse, GetUserResponse, UpdateDeploymentToProdResponse, GetProjectResponse, UpdateProjectResponse, UpdateProjectInput, RedeployToProdResponse, DeleteProjectResponse, GetProjectsInOrganizationResponse, RollbackDeploymentResponse, AddDomainInput, AddDomainResponse, GetDomainsResponse, UpdateDomainInput, UpdateDomainResponse, AuthenticateGithubResponse, UpdateEnvironmentVariableResponse, UpdateEnvironmentVariableInput, RemoveEnvironmentVariableResponse, UpdateProjectMemberInput, RemoveProjectMemberResponse, UpdateProjectMemberResponse } from './types'; import { AddEnvironmentVariableInput, AddEnvironmentVariablesResponse, GetDeploymentsResponse, GetEnvironmentVariablesResponse, GetOrganizationsResponse, GetProjectMembersResponse, SearchProjectsResponse, GetUserResponse, UpdateDeploymentToProdResponse, GetProjectResponse, UpdateProjectResponse, UpdateProjectInput, RedeployToProdResponse, DeleteProjectResponse, GetProjectsInOrganizationResponse, RollbackDeploymentResponse, AddDomainInput, AddDomainResponse, GetDomainsResponse, UpdateDomainInput, UpdateDomainResponse, AuthenticateGitHubResponse, UnauthenticateGitHubResponse, UpdateEnvironmentVariableResponse, UpdateEnvironmentVariableInput, RemoveEnvironmentVariableResponse, UpdateProjectMemberInput, RemoveProjectMemberResponse, UpdateProjectMemberResponse } from './types';
import { removeProjectMember, addEnvironmentVariables, updateDeploymentToProd, updateProjectMutation, redeployToProd, deleteProject, addDomain, rollbackDeployment, updateDomainMutation, authenticateGithub, updateEnvironmentVariable, removeEnvironmentVariable, updateProjectMember } from './mutations'; import { removeProjectMember, addEnvironmentVariables, updateDeploymentToProd, updateProjectMutation, redeployToProd, deleteProject, addDomain, rollbackDeployment, updateDomainMutation, authenticateGitHub, unauthenticateGitHub, updateEnvironmentVariable, removeEnvironmentVariable, updateProjectMember } from './mutations';
export interface GraphQLConfig { export interface GraphQLConfig {
gqlEndpoint: string; gqlEndpoint: string;
@ -263,9 +263,9 @@ export class GQLClient {
return data; return data;
} }
async authenticateGithub (code: string): Promise<AuthenticateGithubResponse> { async authenticateGitHub (code: string): Promise<AuthenticateGitHubResponse> {
const { data } = await this.client.mutate({ const { data } = await this.client.mutate({
mutation: authenticateGithub, mutation: authenticateGitHub,
variables: { variables: {
code code
} }
@ -273,4 +273,12 @@ export class GQLClient {
return data; return data;
} }
async unauthenticateGithub (): Promise<UnauthenticateGitHubResponse> {
const { data } = await this.client.mutate({
mutation: unauthenticateGitHub
});
return data;
}
} }

View File

@ -70,9 +70,14 @@ mutation ($projectId: String!, $domainDetails: AddDomainInput!) {
} }
`; `;
export const authenticateGithub = gql` export const authenticateGitHub = gql`
mutation ($code: String!) { mutation ($code: String!) {
authenticateGithub(code: $code) { authenticateGitHub(code: $code) {
token token
} }
}`; }`;
export const unauthenticateGitHub = gql`
mutation {
unauthenticateGitHub
}`;

View File

@ -8,6 +8,7 @@ query {
email email
createdAt createdAt
updatedAt updatedAt
gitHubToken
} }
} }
`; `;

View File

@ -53,6 +53,7 @@ export type User = {
email: string email: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
gitHubToken: string | null
} }
export type Deployment = { export type Deployment = {
@ -244,8 +245,12 @@ export type AddDomainResponse = {
addDomain: true addDomain: true
} }
export type AuthenticateGithubResponse = { export type AuthenticateGitHubResponse = {
authenticateGithub: { authenticateGitHub: {
token: string token: string
} }
} }
export type UnauthenticateGitHubResponse = {
unauthenticateGitHub: boolean
}