Skip to content

Commit

Permalink
feat: Add oauth flow for querybook github integration
Browse files Browse the repository at this point in the history
  • Loading branch information
zhangvi7 committed Oct 16, 2024
1 parent 7f132ae commit 5a6db84
Show file tree
Hide file tree
Showing 13 changed files with 336 additions and 0 deletions.
3 changes: 3 additions & 0 deletions querybook/server/datasources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from . import comment
from . import survey
from . import query_transform
from . import github


# Keep this at the end of imports to make sure the plugin APIs override the default ones
try:
Expand Down Expand Up @@ -47,3 +49,4 @@
survey
query_transform
api_plugin
github
16 changes: 16 additions & 0 deletions querybook/server/datasources/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from app.datasource import register
from lib.github_integration.github_integration import get_github_manager
from typing import Dict


@register("/github/auth/", methods=["GET"])
def connect_github() -> Dict[str, str]:
github_manager = get_github_manager()
return github_manager.initiate_github_integration()


@register("/github/is_authenticated/", methods=["GET"])
def is_github_authenticated() -> str:
github_manager = get_github_manager()
is_authenticated = github_manager.get_github_token() is not None
return {"is_authenticated": is_authenticated}
Empty file.
110 changes: 110 additions & 0 deletions querybook/server/lib/github_integration/github_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import certifi
from flask import session as flask_session, request
from app.auth.github_auth import GitHubLoginManager
from env import QuerybookSettings
from lib.logger import get_logger
from app.flask_app import flask_app
from typing import Optional, Dict, Any

LOG = get_logger(__file__)


GITHUB_OAUTH_CALLBACK = "/github/oauth2callback"


class GitHubIntegrationManager(GitHubLoginManager):
def __init__(self, additional_scopes: Optional[list] = None):
self.additional_scopes = additional_scopes or []
super().__init__()

@property
def oauth_config(self) -> Dict[str, Any]:
config = super().oauth_config
config["scope"] = "user email " + " ".join(self.additional_scopes)
config[
"callback_url"
] = f"{QuerybookSettings.PUBLIC_URL}{GITHUB_OAUTH_CALLBACK}"
return config

def save_github_token(self, token: str) -> None:
flask_session["github_access_token"] = token
LOG.debug("Saved GitHub token to session")

def get_github_token(self) -> Optional[str]:
return flask_session.get("github_access_token")

def initiate_github_integration(self) -> Dict[str, str]:
github = self.oauth_session
authorization_url, state = github.authorization_url(
self.oauth_config["authorization_url"]
)
flask_session["oauth_state"] = state
return {"url": authorization_url}

def github_integration_callback(self) -> str:
try:
github = self.oauth_session
access_token = github.fetch_token(
self.oauth_config["token_url"],
client_secret=self.oauth_config["client_secret"],
authorization_response=request.url,
cert=certifi.where(),
)
self.save_github_token(access_token["access_token"])
return self.success_response()
except Exception as e:
LOG.error(f"Failed to obtain credentials: {e}")
return self.error_response(str(e))

def success_response(self) -> str:
return """
<p>Success! Please close the tab.</p>
<script>
window.opener.receiveChildMessage()
</script>
"""

def error_response(self, error_message: str) -> str:
return f"""
<p>Failed to obtain credentials, reason: {error_message}</p>
"""


def get_github_manager() -> GitHubIntegrationManager:
return GitHubIntegrationManager(additional_scopes=["repo"])


@flask_app.route(GITHUB_OAUTH_CALLBACK)
def github_callback() -> str:
github_manager = get_github_manager()
return github_manager.github_integration_callback()


# Test GitHub OAuth Flow
def main():
github_manager = GitHubIntegrationManager()
oauth_config = github_manager.oauth_config
client_id = oauth_config["client_id"]
client_secret = oauth_config["client_secret"]

from requests_oauthlib import OAuth2Session

github = OAuth2Session(client_id)
authorization_url, state = github.authorization_url(
oauth_config["authorization_url"]
)
print("Please go here and authorize,", authorization_url)

redirect_response = input("Paste the full redirect URL here:")
github.fetch_token(
oauth_config["token_url"],
client_secret=client_secret,
authorization_response=redirect_response,
)

user_profile = github.get(oauth_config["profile_url"]).json()
print(user_profile)


if __name__ == "__main__":
main()
61 changes: 61 additions & 0 deletions querybook/webapp/components/DataDocGitHub/DataDocGitHubButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React, { useCallback, useEffect, useState } from 'react';

import { GitHubResource } from 'resource/github';
import { IconButton } from 'ui/Button/IconButton';

import { GitHubModal } from './GitHubModal';

interface IProps {
docId: number;
}

export const DataDocGitHubButton: React.FunctionComponent<IProps> = ({
docId,
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);

useEffect(() => {
const checkAuthentication = async () => {
try {
const { data } = await GitHubResource.isAuthenticated();
setIsAuthenticated(data.is_authenticated);
} catch (error) {
console.error(
'Failed to check GitHub authentication status:',
error
);
}
};

checkAuthentication();
}, []);

const handleOpenModal = useCallback(() => {
setIsModalOpen(true);
}, []);

const handleCloseModal = useCallback(() => {
setIsModalOpen(false);
}, []);

return (
<>
<IconButton
icon="Github"
onClick={handleOpenModal}
tooltip="Connect to GitHub"
tooltipPos="left"
title="GitHub"
/>
{isModalOpen && (
<GitHubModal
docId={docId}
isAuthenticated={isAuthenticated}
setIsAuthenticated={setIsAuthenticated}
onClose={handleCloseModal}
/>
)}
</>
);
};
8 changes: 8 additions & 0 deletions querybook/webapp/components/DataDocGitHub/GitHub.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.GitHubAuth {
text-align: center;
padding: 20px;
}

.GitHubAuth-icon {
margin-bottom: 20px;
}
31 changes: 31 additions & 0 deletions querybook/webapp/components/DataDocGitHub/GitHubAuth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';

import { Button } from 'ui/Button/Button';
import { Icon } from 'ui/Icon/Icon';
import { Message } from 'ui/Message/Message';

import './GitHub.scss';

interface IProps {
onAuthenticate: () => void;
}

export const GitHubAuth: React.FunctionComponent<IProps> = ({
onAuthenticate,
}) => (
<div className="GitHubAuth">
<Icon name="Github" size={64} className="GitHubAuth-icon" />
<Message
title="Connect to GitHub"
message="You currently do not have a GitHub provider linked to your account. Please authenticate to enable GitHub features on Querybook."
type="info"
iconSize={32}
/>
<Button
onClick={onAuthenticate}
title="Connect Now"
color="accent"
theme="fill"
/>
</div>
);
82 changes: 82 additions & 0 deletions querybook/webapp/components/DataDocGitHub/GitHubModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useCallback, useState } from 'react';

import { ComponentType, ElementType } from 'const/analytics';
import { trackClick } from 'lib/analytics';
import { GitHubResource, IGitHubAuthResponse } from 'resource/github';
import { Message } from 'ui/Message/Message';
import { Modal } from 'ui/Modal/Modal';

import { GitHubAuth } from './GitHubAuth';

interface IProps {
docId: number;
isAuthenticated: boolean;
setIsAuthenticated: (isAuthenticated: boolean) => void;
onClose: () => void;
}

export const GitHubModal: React.FunctionComponent<IProps> = ({
docId,
isAuthenticated,
setIsAuthenticated,
onClose,
}) => {
const [errorMessage, setErrorMessage] = useState<string>(null);

const handleConnectGitHub = useCallback(async () => {
trackClick({
component: ComponentType.DATADOC_PAGE,
element: ElementType.GITHUB_CONNECT_BUTTON,
});

try {
const { data }: { data: IGitHubAuthResponse } =
await GitHubResource.connectGithub();
const url = data.url;
if (!url) {
throw new Error('Failed to get GitHub authentication URL');
}
const authWindow = window.open(url);

const receiveMessage = () => {
authWindow.close();
delete window.receiveChildMessage;
window.removeEventListener('message', receiveMessage, false);
setIsAuthenticated(true);
};
window.receiveChildMessage = receiveMessage;

// If the user closes the authentication window manually, clean up
const timer = setInterval(() => {
if (authWindow.closed) {
clearInterval(timer);
window.removeEventListener(
'message',
receiveMessage,
false
);
throw new Error('Authentication process failed');
}
}, 1000);
} catch (error) {
console.error('GitHub authentication failed:', error);
setErrorMessage('GitHub authentication failed. Please try again.');
}
}, [setIsAuthenticated]);

return (
<Modal onHide={onClose} title="GitHub Integration">
<div className="GitHubModal-content">
{isAuthenticated ? (
<Message message="Connected to GitHub!" type="success" />
) : (
<GitHubAuth onAuthenticate={handleConnectGitHub} />
)}
{errorMessage && (
<Message message={errorMessage} type="error" />
)}
<button onClick={onClose}>Close</button>
</div>
</Modal>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';

import { DataDocBoardsButton } from 'components/DataDocBoardsButton/DataDocBoardsButton';
import { DataDocDAGExporterButton } from 'components/DataDocDAGExporter/DataDocDAGExporterButton';
import { DataDocGitHubButton } from 'components/DataDocGitHub/DataDocGitHubButton';
import { DataDocTemplateButton } from 'components/DataDocTemplateButton/DataDocTemplateButton';
import { DataDocUIGuide } from 'components/UIGuide/DataDocUIGuide';
import { ComponentType, ElementType } from 'const/analytics';
Expand Down Expand Up @@ -83,6 +84,8 @@ export const DataDocRightSidebar: React.FunctionComponent<IProps> = ({
<DataDocRunAllButton docId={dataDoc.id} />
);

const githubButtonDOM = <DataDocGitHubButton docId={dataDoc.id} />;

const buttonSection = (
<div className="DataDocRightSidebar-button-section vertical-space-between">
<div className="DataDocRightSidebar-button-section-top flex-column">
Expand Down Expand Up @@ -131,6 +134,7 @@ export const DataDocRightSidebar: React.FunctionComponent<IProps> = ({
</div>
<div className="DataDocRightSidebar-button-section-bottom flex-column mb8">
{runAllButtonDOM}
{githubButtonDOM}
{isEditable && exporterExists && (
<DataDocDAGExporterButton docId={dataDoc.id} />
)}
Expand Down
4 changes: 4 additions & 0 deletions querybook/webapp/const/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ export enum ElementType {
QUERY_GENERATION_REJECT_BUTTON = 'QUERY_GENERATION_REJECT_BUTTON',
QUERY_GENERATION_APPLY_BUTTON = 'QUERY_GENERATION_APPLY_BUTTON',
QUERY_GENERATION_APPLY_AND_RUN_BUTTON = 'QUERY_GENERATION_APPLY_AND_RUN_BUTTON',

// Github Integration
GITHUB_CONNECT_BUTTON = 'GITHUB_CONNECT_BUTTON',
GITHUB_LINK_BUTTON = 'GITHUB_LINK_BUTTON',
}

export interface EventData {
Expand Down
11 changes: 11 additions & 0 deletions querybook/webapp/resource/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import ds from 'lib/datasource';

export interface IGitHubAuthResponse {
url: string;
}

export const GitHubResource = {
connectGithub: () => ds.fetch<IGitHubAuthResponse>('/github/auth/'),
isAuthenticated: () =>
ds.fetch<{ is_authenticated: boolean }>('/github/is_authenticated/'),
};
2 changes: 2 additions & 0 deletions querybook/webapp/ui/Icon/LucideIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
FileText,
Filter,
FormInput,
Github,
GripVertical,
Hash,
HelpCircle,
Expand Down Expand Up @@ -167,6 +168,7 @@ const AllLucideIcons = {
FileText,
Filter,
FormInput,
Github,
GripVertical,
Hash,
HelpCircle,
Expand Down
Loading

0 comments on commit 5a6db84

Please sign in to comment.