diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..46da2aa --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# The client ID you received from GitHub for your GitHub App. +GITHUB_OAUTH_CLIENT_ID=GITHUB_OAUTH_CLIENT_ID + +# The client secret you received from GitHub for your GitHub App. +GITHUB_OAUTH_CLIENT_SECRET=GITHUB_OAUTH_CLIENT_SECRET diff --git a/.gitignore b/.gitignore index 21e353a..92b0d2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .vscode +.env node_modules tmp dist diff --git a/package.json b/package.json index 01ce5db..d5946e8 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "babel-loader": "^8.0.5", "copy-webpack-plugin": "^5.0.0", "css-loader": "^2.1.0", + "dotenv-webpack": "^1.7.0", "file-loader": "^3.0.1", "fork-ts-checker-webpack-plugin": "^0.5.2", "fsevents": "^1.2.4", diff --git a/src/components/SettingsBar.tsx b/src/components/SettingsBar.tsx index 2b13f53..0976cb4 100644 --- a/src/components/SettingsBar.tsx +++ b/src/components/SettingsBar.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import Icon from 'components/Icon' import configHelper, { configKeys } from 'utils/configHelper' import keyHelper from 'utils/keyHelper' -import { friendlyFormatShortcut } from 'utils/general' +import { friendlyFormatShortcut, parseURLSearch, JSONRequest } from 'utils/general' import { version } from '../../package.json' const wikiLinks = { @@ -14,6 +14,11 @@ const wikiLinks = { 'https://github.com/EnixCoda/Gitako/wiki/How-to-create-access-token-for-Gitako%3F', } +const oauth = { + clientId: process.env.GITHUB_OAUTH_CLIENT_ID, + clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET, +} + const ACCESS_TOKEN_REGEXP = /^[0-9a-f]{40}$/ type Props = { @@ -84,12 +89,33 @@ export default class SettingsBar extends React.PureComponent { ], } + componentDidMount() { + if (!this.props.accessToken) this.trySetUpAccessTokenWithCode() + } + componentWillReceiveProps({ toggleShowSideBarShortcut }: Props) { if (toggleShowSideBarShortcut !== this.props.toggleShowSideBarShortcut) { this.setState({ toggleShowSideBarShortcut }) } } + private async trySetUpAccessTokenWithCode() { + const search = parseURLSearch() + if ('code' in search) { + const res = await JSONRequest('https://github.com/login/oauth/access_token', { + code: search.code, + client_id: oauth.clientId, + client_secret: oauth.clientSecret, + }) + const { access_token: accessToken, scope } = res + if (scope !== 'repo' || !accessToken) { + throw new Error(`Cannot resolve token response: '${JSON.stringify(res)}'`) + } + window.history.pushState({}, 'removed code', window.location.pathname.replace(/#.*$/, '')) + this.setState({ accessToken }, () => this.saveToken('')) + } + } + onInputAccessToken = (event: React.FormEvent) => { const { value } = event.currentTarget this.setState({ @@ -105,7 +131,16 @@ export default class SettingsBar extends React.PureComponent { } } - saveToken = async () => { + saveToken = async ( + hint: State['accessTokenHint'] = ( + + window.location.reload()}> + Reload + {' '} + to activate! + + ), + ) => { const { onAccessTokenChange } = this.props const { accessToken } = this.state if (accessToken) { @@ -113,14 +148,7 @@ export default class SettingsBar extends React.PureComponent { onAccessTokenChange(accessToken) this.setState({ accessToken: '', - accessTokenHint: ( - - window.location.reload()}> - Reload - {' '} - to activate! - - ), + accessTokenHint: hint, }) } } @@ -196,16 +224,31 @@ export default class SettingsBar extends React.PureComponent {
-

Access Token

- - Why & how to create it? - -
+

+ Access Token + +  (?) + +

+ {!hasAccessToken && ( + { + // use js here to make sure redirect_uri is latest url + const url = `https://github.com/login/oauth/authorize?client_id=${ + oauth.clientId + }&scope=repo&redirect_uri=${encodeURIComponent(window.location.href)}` + window.location.href = url + }} + > + Create with OAuth (recommended) + + )}
(mapping: Sources) } } } + +export function promisifyGetState( + get: PreDispatch, +): () => Promise { + return () => new Promise(resolve => get(state => resolve(state))) +} diff --git a/src/driver/core/SideBar.ts b/src/driver/core/SideBar.ts index 70aed34..cba88d4 100644 --- a/src/driver/core/SideBar.ts +++ b/src/driver/core/SideBar.ts @@ -11,7 +11,7 @@ import GitHubHelper, { import configHelper from 'utils/configHelper' import URLHelper from 'utils/URLHelper' import keyHelper from 'utils/keyHelper' -import { MethodCreator } from 'driver/connect' +import { MethodCreator, promisifyGetState } from 'driver/connect' import { Props } from 'components/SideBar' import SettingsBar from 'components/SettingsBar' @@ -40,6 +40,7 @@ export type ConnectorState = { copySnippetButton: boolean logoContainerElement: Element | null disabled: boolean + initializingPromise: Promise | null init: () => void onPJAXEnd: () => void @@ -55,12 +56,24 @@ export type ConnectorState = { } const init: MethodCreator = dispatch => async () => { + const { initializingPromise } = await promisifyGetState(dispatch.get)() + if (initializingPromise) await initializingPromise + + let done: any = null // cannot use type `(() => void) | null` here + dispatch.set({ + initializingPromise: new Promise(resolve => { + done = () => resolve() + }), + }) + try { if (!URLHelper.isInRepoPage()) { dispatch.set({ disabled: true }) return } dispatch.set({ + errorDueToAuth: false, + showSettings: false, logoContainerElement: DOMHelper.insertLogoMountPoint(), }) let detectedBranchName @@ -139,6 +152,8 @@ const init: MethodCreator = dispatch => async () => { DOMHelper.markGitakoReadyState() } catch (err) { dispatch.call(handleError, err) + } finally { + if (done) done() } } @@ -219,7 +234,13 @@ const onAccessTokenChange: MethodCreator< Props, ConnectorState, [ConnectorState['accessToken']] -> = dispatch => accessToken => dispatch.set({ accessToken }) +> = dispatch => accessToken => { + dispatch.set({ accessToken }) + // reload when setting new accessToken + if (accessToken) { + dispatch.call(init) + } +} const onShortcutChange: MethodCreator< Props, diff --git a/src/utils/general.ts b/src/utils/general.ts index 91056f3..742e08a 100644 --- a/src/utils/general.ts +++ b/src/utils/general.ts @@ -93,3 +93,38 @@ export function createStyleSheet(content: string) { export function setStyleSheetMedia(style: HTMLStyleElement, media: string) { style.setAttribute('media', media) } + +export function parseURLSearch(search: string = window.location.search) { + const parsed: any = {} + if (search.startsWith('?')) { + const pairs = search + .slice(1) + .split('&') + .map(rawPair => rawPair.split('=').map(raw => decodeURIComponent(raw))) // [key, value?][] + pairs.forEach(([key, value]) => { + if (Object.prototype.hasOwnProperty.call(parsed, key)) { + if (Array.isArray(parsed[key])) parsed[key].push(value) + else parsed[key] = [parsed[key], value] + } else { + parsed[key] = value + } + }) + } + return parsed +} + +export async function JSONRequest(url: string, data: any, method = 'post') { + return (await fetch(url, { + method, + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + redirect: 'follow', + referrer: 'no-referrer', + body: JSON.stringify(data), + })).json() +} diff --git a/webpack.config.js b/webpack.config.js index 29228d5..39fef34 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -2,6 +2,7 @@ const webpack = require('webpack') const CopyWebpackPlugin = require('copy-webpack-plugin') const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin') const path = require('path') +const Dotenv = require('dotenv-webpack') const srcPath = path.resolve(__dirname, 'src') const packagesPath = path.resolve(__dirname, 'packages') @@ -18,6 +19,7 @@ const plugins = [ }, ]), new ForkTsCheckerWebpackPlugin(), + new Dotenv(), ] const IN_PRODUCTION_MODE = process.env.NODE_ENV === 'production' @@ -27,7 +29,7 @@ if (IN_PRODUCTION_MODE) { 'process.env': { NODE_ENV: JSON.stringify('production'), }, - }) + }), ) } diff --git a/yarn.lock b/yarn.lock index 7f91352..f622fb5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2197,6 +2197,25 @@ domutils@^1.7.0: dom-serializer "0" domelementtype "1" +dotenv-defaults@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/dotenv-defaults/-/dotenv-defaults-1.0.2.tgz#441cf5f067653fca4bbdce9dd3b803f6f84c585d" + integrity sha512-iXFvHtXl/hZPiFj++1hBg4lbKwGM+t/GlvELDnRtOFdjXyWP7mubkVr+eZGWG62kdsbulXAef6v/j6kiWc/xGA== + dependencies: + dotenv "^6.2.0" + +dotenv-webpack@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/dotenv-webpack/-/dotenv-webpack-1.7.0.tgz#4384d8c57ee6f405c296278c14a9f9167856d3a1" + integrity sha512-wwNtOBW/6gLQSkb8p43y0Wts970A3xtNiG/mpwj9MLUhtPCQG6i+/DSXXoNN7fbPCU/vQ7JjwGmgOeGZSSZnsw== + dependencies: + dotenv-defaults "^1.0.2" + +dotenv@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.2.0.tgz#941c0410535d942c8becf28d3f357dbd9d476064" + integrity sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w== + duplexify@^3.4.2, duplexify@^3.6.0: version "3.7.1" resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309"