diff --git a/infrastructure/testing/jestSetupFramework.js b/infrastructure/testing/jestSetupFramework.js new file mode 100644 index 00000000..33f5dd28 --- /dev/null +++ b/infrastructure/testing/jestSetupFramework.js @@ -0,0 +1,9 @@ +const moxios = require('moxios') + +beforeEach(() => { + moxios.install() +}) + +afterEach(() => { + moxios.uninstall() +}) diff --git a/package.json b/package.json index d207124b..d8038073 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "setupFiles": [ "./infrastructure/testing/jestSetupFile.js" ], + "setupTestFrameworkScriptFile": "./infrastructure/testing/jestSetupFramework.js", "collectCoverage": true, "coverageDirectory": "artifacts/test_results/jest/coverage" }, @@ -30,7 +31,8 @@ "lodash.merge": "^4.6.1", "mustache-express": "^1.2.5", "serverless-dynamodb-local": "^0.2.30", - "serverless-http": "^1.5.5" + "serverless-http": "^1.5.5", + "uuid": "^3.2.1" }, "devDependencies": { "coveralls": "^3.0.1", @@ -44,6 +46,7 @@ "jest": "^22.4.3", "jest-junit": "^4.0.0", "lint-staged": "^7.0.0", + "moxios": "^0.4.0", "prettier": "^1.10.2", "serverless": "^1.26.1", "serverless-domain-manager": "^2.3.6", diff --git a/serverless.yml b/serverless.yml index c47f1353..2aa3702b 100644 --- a/serverless.yml +++ b/serverless.yml @@ -14,14 +14,6 @@ custom: port: 8000 migrate: true inMemory: true - githubClientId: - dev: '04fcf325dd26ca2a159f' - stage: '04fcf325dd26ca2a159f' - prod: '04fcf325dd26ca2a159f' - githubClientSecret: - dev: ${env:GITHUB_CLIENT_SECRET} - stage: ${env:GITHUB_CLIENT_SECRET} - prod: ${env:GITHUB_CLIENT_SECRET} customDomain: domainName: service.bundlewatch.io basePath: '' @@ -48,8 +40,10 @@ provider: - { "Fn::GetAtt": ["StoreTable", "Arn" ] } environment: STORE_TABLE: ${self:custom.storeTable} - GITHUB_CLIENT_ID: ${self:custom.githubClientId.${self:custom.stage}} - GITHUB_CLIENT_SECRET: ${self:custom.githubClientSecret.${self:custom.stage}} + GITHUB_CLIENT_ID: '04fcf325dd26ca2a159f' + GITHUB_CLIENT_SECRET: ${env:GITHUB_CLIENT_SECRET} + GITHUB_APP_CLIENT_ID: 'Iv1.3392d0790b8f8334' + GITHUB_APP_CLIENT_SECRET: ${env:GITHUB_APP_CLIENT_SECRET} functions: expressRouter: @@ -76,9 +70,15 @@ functions: - http: path: /static/setup-github-styles.css method: get + - http: + path: /static/manage-styles.css + method: get - http: path: /analyze method: post + - http: + path: /manage + method: get diff --git a/src/app/authentication/getRepositoryTokens.js b/src/app/authentication/getRepositoryTokens.js new file mode 100644 index 00000000..c534dfcb --- /dev/null +++ b/src/app/authentication/getRepositoryTokens.js @@ -0,0 +1,28 @@ +const uuid = require('uuid') + +const { getRepoToken, saveRepoToken } = require('../../models/storeUtils') + +const getTokenForRepo = async repoFullName => { + const details = await getRepoToken(repoFullName) + let token = details.token + if (!token) { + token = uuid.v4() + await saveRepoToken(repoFullName) + } + return token +} + + +const getRepositoryTokens = async repositories => { + return repositories.map(async repoFullName => { + const repoToken = await getTokenForRepo(repoFullName) + return { + repoFullName, + repoToken, + } + }) +} + +module.exports = { + getRepositoryTokens, +} diff --git a/src/app/getBranchFileDetails.js b/src/app/getBranchFileDetails.js deleted file mode 100644 index c131ee8f..00000000 --- a/src/app/getBranchFileDetails.js +++ /dev/null @@ -1,13 +0,0 @@ -const { Store } = require('../models/store') - -const getBranchFileDetails = (repoOwner, repoName, repoBranch) => { - const repo = `${repoOwner}/${repoName}` - return Store.get({ - repoBranch, - repo, - }) -} - -module.exports = { - getBranchFileDetails, -} diff --git a/src/app/getEnv.js b/src/app/getEnv.js new file mode 100644 index 00000000..0db0f299 --- /dev/null +++ b/src/app/getEnv.js @@ -0,0 +1,16 @@ +const getEnv = key => { + const value = process.env[key] + if ( + !value || + value.length === 0 || + value == 'undefined' || // eslint-disable-line eqeqeq + value == 'null' // eslint-disable-line eqeqeq + ) { + throw new Error(`Env var ${key} is missing`) + } + return value +} + +module.exports = { + getEnv, +} diff --git a/src/app/github/app/getAppJWT.js b/src/app/github/app/getAppJWT.js new file mode 100644 index 00000000..4d616abb --- /dev/null +++ b/src/app/github/app/getAppJWT.js @@ -0,0 +1,25 @@ +const fs = require('fs') +const jwt = require('jsonwebtoken') + +const PRIVATE_KEY_PATH = 'github-key.pem' +const TEN_MINUTES = 10 * 60 +const GITHUB_APP_IDENTIFIER = 12145 + +const getAppJWT = () => { + const privateKey = fs.readFileSync(PRIVATE_KEY_PATH) + const nowSeconds = Date.now() / 1000 + const issuedAt = nowSeconds + const expiration = nowSeconds + TEN_MINUTES + const token = jwt.encode( + { + iat: issuedAt, + exp: expiration, + iss: GITHUB_APP_IDENTIFIER, + }, + privateKey, + 'RS256', + ) + return token +} + +module.exports = getAppJWT diff --git a/src/app/github/getRepositoriesForUser.js b/src/app/github/getRepositoriesForUser.js new file mode 100644 index 00000000..7914770a --- /dev/null +++ b/src/app/github/getRepositoriesForUser.js @@ -0,0 +1,28 @@ +// const logger = require('../../logger') + +const { + generateUserAccessTokenWithCode, +} = require('./user/generateUserAccessTokenWithCode') +const { Installations } = require('./user/Installations') + +const getRepositoriesForUser = async code => { + const githubUserAccessToken = await generateUserAccessTokenWithCode(code) + const installationService = new Installations({ githubUserAccessToken }) + const installations = await installationService.getInstallations() + const repositories = [] + const repoFetchPromises = installations.map(installationId => { + return installationService + .getRepositoriesForInstallation(installationId) + .then(installationRepos => { + installationRepos.forEach(repo => { + repositories.push(repo) + }) + }) + }) + await Promise.all(repoFetchPromises) + return repositories +} + +module.exports = { + getRepositoriesForUser, +} diff --git a/src/app/github/user/Installations.js b/src/app/github/user/Installations.js new file mode 100644 index 00000000..aa8e1e43 --- /dev/null +++ b/src/app/github/user/Installations.js @@ -0,0 +1,61 @@ +const axios = require('axios') + +const logger = require('../../../logger') + +class Installations { + constructor({ githubUserAccessToken }) { + this.githubUserAccessToken = githubUserAccessToken + } + + getInstallations() { + return axios({ + method: 'GET', + url: `https://api.github.com/user/installations`, + responseType: 'json', + timeout: 3000, + headers: { + Accept: `application/vnd.github.machine-man-preview+json`, + Authorization: `token ${this.githubUserAccessToken}`, + }, + }) + .then(response => { + const installationIds = response.data.installations.map( + installation => { + return installation.id + }, + ) + return installationIds + }) + .catch(error => { + logger.debug(error) + throw error + }) + } + + getRepositoriesForInstallation(installationId) { + return axios({ + method: 'GET', + url: `https://api.github.com/user/installations/${installationId}/repositories`, + responseType: 'json', + timeout: 3000, + headers: { + Accept: `application/vnd.github.machine-man-preview+json`, + Authorization: `token ${this.githubUserAccessToken}`, + }, + }) + .then(response => { + const repoFullNames = response.data.repositories.map(repo => { + return repo.full_name + }) + return repoFullNames + }) + .catch(error => { + logger.debug(error) + throw error + }) + } +} + +module.exports = { + Installations, +} diff --git a/src/app/github/user/generateUserAccessTokenWithCode.js b/src/app/github/user/generateUserAccessTokenWithCode.js new file mode 100644 index 00000000..07796e44 --- /dev/null +++ b/src/app/github/user/generateUserAccessTokenWithCode.js @@ -0,0 +1,41 @@ +const axios = require('axios') + +const logger = require('../../../logger') +const { getEnv } = require('../../getEnv') + +const generateUserAccessTokenWithCode = code => { + const clientId = getEnv('GITHUB_APP_CLIENT_ID') + const clientSecret = getEnv('GITHUB_APP_CLIENT_SECRET') + + return axios({ + method: 'POST', + url: 'https://github.com/login/oauth/access_token', + headers: { + Accept: 'application/json', + 'Content-type': 'application/json', + }, + data: { + code, + client_id: clientId, + client_secret: clientSecret, + }, + timeout: 10000, + }).then(response => { + if (response.data.error) { + logger.debug(response.data) + throw new Error(response.data.error) + } + + if (response.data.access_token) { + logger.debug(`Token: ${response.data.access_token}`) + return response.data.access_token + } + + logger.debug(response) + throw new Error('Could not get token') + }) +} + +module.exports = { + generateUserAccessTokenWithCode, +} diff --git a/src/app/github/user/installations.mockdata.js b/src/app/github/user/installations.mockdata.js new file mode 100644 index 00000000..d59c46ff --- /dev/null +++ b/src/app/github/user/installations.mockdata.js @@ -0,0 +1,89 @@ +const getInstallationsResponse = { + total_count: 2, + installations: [ + { + id: 1, + account: { + login: 'github', + id: 1, + url: 'https://api.github.com/orgs/github', + repos_url: 'https://api.github.com/orgs/github/repos', + events_url: 'https://api.github.com/orgs/github/events', + hooks_url: 'https://api.github.com/orgs/github/hooks', + issues_url: 'https://api.github.com/orgs/github/issues', + members_url: + 'https://api.github.com/orgs/github/members{/member}', + public_members_url: + 'https://api.github.com/orgs/github/public_members{/member}', + avatar_url: 'https://github.com/images/error/octocat_happy.gif', + description: 'A great organization', + }, + access_tokens_url: + 'https://api.github.com/installations/1/access_tokens', + repositories_url: + 'https://api.github.com/installation/repositories', + html_url: + 'https://github.com/organizations/github/settings/installations/1', + app_id: 1, + target_id: 1, + target_type: 'Organization', + permissions: { + metadata: 'read', + contents: 'read', + issues: 'write', + single_file: 'write', + }, + events: ['push', 'pull_request'], + single_file_name: 'config.yml', + }, + { + id: 3, + account: { + login: 'octocat', + id: 2, + avatar_url: 'https://github.com/images/error/octocat_happy.gif', + gravatar_id: '', + url: 'https://api.github.com/users/octocat', + html_url: 'https://github.com/octocat', + followers_url: 'https://api.github.com/users/octocat/followers', + following_url: + 'https://api.github.com/users/octocat/following{/other_user}', + gists_url: + 'https://api.github.com/users/octocat/gists{/gist_id}', + starred_url: + 'https://api.github.com/users/octocat/starred{/owner}{/repo}', + subscriptions_url: + 'https://api.github.com/users/octocat/subscriptions', + organizations_url: 'https://api.github.com/users/octocat/orgs', + repos_url: 'https://api.github.com/users/octocat/repos', + events_url: + 'https://api.github.com/users/octocat/events{/privacy}', + received_events_url: + 'https://api.github.com/users/octocat/received_events', + type: 'User', + site_admin: false, + }, + access_tokens_url: + 'https://api.github.com/installations/1/access_tokens', + repositories_url: + 'https://api.github.com/installation/repositories', + html_url: + 'https://github.com/organizations/github/settings/installations/1', + app_id: 1, + target_id: 1, + target_type: 'Organization', + permissions: { + metadata: 'read', + contents: 'read', + issues: 'write', + single_file: 'write', + }, + events: ['push', 'pull_request'], + single_file_name: 'config.yml', + }, + ], +} + +module.exports = { + getInstallationsResponse, +} diff --git a/src/app/github/user/installations.repositories.mockdata.js b/src/app/github/user/installations.repositories.mockdata.js new file mode 100644 index 00000000..e77ff010 --- /dev/null +++ b/src/app/github/user/installations.repositories.mockdata.js @@ -0,0 +1,144 @@ +const getInstallationRepositoriesResponse = { + total_count: 1, + repositories: [ + { + id: 1296269, + owner: { + login: 'octocat', + id: 1, + avatar_url: 'https://github.com/images/error/octocat_happy.gif', + gravatar_id: '', + url: 'https://api.github.com/users/octocat', + html_url: 'https://github.com/octocat', + followers_url: 'https://api.github.com/users/octocat/followers', + following_url: + 'https://api.github.com/users/octocat/following{/other_user}', + gists_url: + 'https://api.github.com/users/octocat/gists{/gist_id}', + starred_url: + 'https://api.github.com/users/octocat/starred{/owner}{/repo}', + subscriptions_url: + 'https://api.github.com/users/octocat/subscriptions', + organizations_url: 'https://api.github.com/users/octocat/orgs', + repos_url: 'https://api.github.com/users/octocat/repos', + events_url: + 'https://api.github.com/users/octocat/events{/privacy}', + received_events_url: + 'https://api.github.com/users/octocat/received_events', + type: 'User', + site_admin: false, + }, + name: 'Hello-World', + full_name: 'octocat/Hello-World', + description: 'This your first repo!', + private: false, + fork: true, + url: 'https://api.github.com/repos/octocat/Hello-World', + html_url: 'https://github.com/octocat/Hello-World', + archive_url: + 'http://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}', + assignees_url: + 'http://api.github.com/repos/octocat/Hello-World/assignees{/user}', + blobs_url: + 'http://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}', + branches_url: + 'http://api.github.com/repos/octocat/Hello-World/branches{/branch}', + clone_url: 'https://github.com/octocat/Hello-World.git', + collaborators_url: + 'http://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}', + comments_url: + 'http://api.github.com/repos/octocat/Hello-World/comments{/number}', + commits_url: + 'http://api.github.com/repos/octocat/Hello-World/commits{/sha}', + compare_url: + 'http://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}', + contents_url: + 'http://api.github.com/repos/octocat/Hello-World/contents/{+path}', + contributors_url: + 'http://api.github.com/repos/octocat/Hello-World/contributors', + deployments_url: + 'http://api.github.com/repos/octocat/Hello-World/deployments', + downloads_url: + 'http://api.github.com/repos/octocat/Hello-World/downloads', + events_url: + 'http://api.github.com/repos/octocat/Hello-World/events', + forks_url: 'http://api.github.com/repos/octocat/Hello-World/forks', + git_commits_url: + 'http://api.github.com/repos/octocat/Hello-World/git/commits{/sha}', + git_refs_url: + 'http://api.github.com/repos/octocat/Hello-World/git/refs{/sha}', + git_tags_url: + 'http://api.github.com/repos/octocat/Hello-World/git/tags{/sha}', + git_url: 'git:github.com/octocat/Hello-World.git', + hooks_url: 'http://api.github.com/repos/octocat/Hello-World/hooks', + issue_comment_url: + 'http://api.github.com/repos/octocat/Hello-World/issues/comments{/number}', + issue_events_url: + 'http://api.github.com/repos/octocat/Hello-World/issues/events{/number}', + issues_url: + 'http://api.github.com/repos/octocat/Hello-World/issues{/number}', + keys_url: + 'http://api.github.com/repos/octocat/Hello-World/keys{/key_id}', + labels_url: + 'http://api.github.com/repos/octocat/Hello-World/labels{/name}', + languages_url: + 'http://api.github.com/repos/octocat/Hello-World/languages', + merges_url: + 'http://api.github.com/repos/octocat/Hello-World/merges', + milestones_url: + 'http://api.github.com/repos/octocat/Hello-World/milestones{/number}', + mirror_url: 'git:git.example.com/octocat/Hello-World', + notifications_url: + 'http://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}', + pulls_url: + 'http://api.github.com/repos/octocat/Hello-World/pulls{/number}', + releases_url: + 'http://api.github.com/repos/octocat/Hello-World/releases{/id}', + ssh_url: 'git@github.com:octocat/Hello-World.git', + stargazers_url: + 'http://api.github.com/repos/octocat/Hello-World/stargazers', + statuses_url: + 'http://api.github.com/repos/octocat/Hello-World/statuses/{sha}', + subscribers_url: + 'http://api.github.com/repos/octocat/Hello-World/subscribers', + subscription_url: + 'http://api.github.com/repos/octocat/Hello-World/subscription', + svn_url: 'https://svn.github.com/octocat/Hello-World', + tags_url: 'http://api.github.com/repos/octocat/Hello-World/tags', + teams_url: 'http://api.github.com/repos/octocat/Hello-World/teams', + trees_url: + 'http://api.github.com/repos/octocat/Hello-World/git/trees{/sha}', + homepage: 'https://github.com', + language: null, + forks_count: 9, + stargazers_count: 80, + watchers_count: 80, + size: 108, + default_branch: 'master', + open_issues_count: 0, + topics: ['octocat', 'atom', 'electron', 'API'], + has_issues: true, + has_wiki: true, + has_pages: false, + has_downloads: true, + archived: false, + pushed_at: '2011-01-26T19:06:43Z', + created_at: '2011-01-26T19:01:12Z', + updated_at: '2011-01-26T19:14:43Z', + permissions: { + admin: false, + push: false, + pull: true, + }, + allow_rebase_merge: true, + allow_squash_merge: true, + allow_merge_commit: true, + subscribers_count: 42, + network_count: 0, + }, + ], +} + +module.exports = { + getInstallationRepositoriesResponse, +} diff --git a/src/app/github/user/installations.test.js b/src/app/github/user/installations.test.js new file mode 100644 index 00000000..68302163 --- /dev/null +++ b/src/app/github/user/installations.test.js @@ -0,0 +1,38 @@ +const moxios = require('moxios') +const { Installations } = require('./Installations') +const { getInstallationsResponse } = require('./installations.mockdata') +const { + getInstallationRepositoriesResponse, +} = require('./installations.repositories.mockdata') + +describe('github/user/installations', () => { + const MOCK_ACCESS_TOKEN = 'mock_token' + const installationApi = new Installations({ + githubUserAccessToken: MOCK_ACCESS_TOKEN, + }) + it('getInstallations, Returns correct response', async () => { + moxios.stubRequest('https://api.github.com/user/installations', { + status: 200, + response: getInstallationsResponse, + }) + + const installations = await installationApi.getInstallations() + expect(installations).toEqual([1, 3]) + }) + + it('getRepositoriesForInstallation, returns correct response', async () => { + const mockInstallationId = 1 + moxios.stubRequest( + `https://api.github.com/user/installations/${mockInstallationId}/repositories`, + { + status: 200, + response: getInstallationRepositoriesResponse, + }, + ) + + const installations = await installationApi.getRepositoriesForInstallation( + mockInstallationId, + ) + expect(installations).toEqual(['octocat/Hello-World']) + }) +}) diff --git a/src/app/index.js b/src/app/index.js index e41cf1c5..e0718fec 100755 --- a/src/app/index.js +++ b/src/app/index.js @@ -1,6 +1,6 @@ const { analyze } = require('./analyze') const { createURL: createURLToResultPage } = require('./resultsPage/createURL') -const { getBranchFileDetails } = require('./getBranchFileDetails') +const { getBranchFileDetails } = require('../models/storeUtils') const { GitHubService } = require('./github/GitHubService') const logger = require('../logger') const { STATUSES } = require('./analyze/analyzeFiles') diff --git a/src/models/storeUtils.js b/src/models/storeUtils.js new file mode 100644 index 00000000..c306ae77 --- /dev/null +++ b/src/models/storeUtils.js @@ -0,0 +1,28 @@ +const { Store } = require('./store') + +const getBranchFileDetails = (repoOwner, repoName, repoBranch) => { + const repo = `${repoOwner}/${repoName}` + return Store.get({ + repoBranch, + repo, + }) +} + +const getRepoToken = repoFullName => { + return Store.get({ + repo: repoFullName, + }) +} + +const saveRepoToken = repoFullName => { + return Promise.resolve() + // return Store.get({ + // repo: repoFullName, + // }) +} + +module.exports = { + getBranchFileDetails, + getRepoToken, + saveRepoToken, +} diff --git a/src/router/index.js b/src/router/index.js index 6931abbe..03c06e44 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -6,7 +6,6 @@ const jsonpack = require('jsonpack/main') const mustacheExpress = require('mustache-express') const serverless = require('serverless-http') -const { Store } = require('../models/store') const { analyzeSchema, createStoreSchema, @@ -18,7 +17,13 @@ const { generateAccessToken } = require('../app/github/generateAccessToken') const { asyncMiddleware } = require('./middleware/asyncMiddleware') const { bundlewatchAsync, STATUSES } = require('../app') const { protectedMiddleware } = require('./middleware/protectedMiddleware') -const { getBranchFileDetails } = require('../app/getBranchFileDetails') +const { getBranchFileDetails } = require('../models/storeUtils') +const { + getRepositoriesForUser, +} = require('../app/github/getRepositoriesForUser') +const { + getRepositoryTokens, +} = require('../app/authentication/getRepositoryTokens') const getMustachePropsFromStatus = status => { if (status === STATUSES.PASS) { @@ -121,7 +126,6 @@ function createServerlessApp() { asyncMiddleware(async (req, res) => { const errorStatus = validateEndpoint(req, res, githutTokenSchema) if (errorStatus) return errorStatus - if (errorStatus) return errorStatus const { code } = req.query let result if (code) { @@ -135,6 +139,35 @@ function createServerlessApp() { return res.render('setup-github', { token: result }) }), ) + app.get( + '/manage', + asyncMiddleware(async (req, res) => { + // const callbackURL = encodeURI( + // `https://service.bundlewatch.io/manage`, + // ) + const callbackURL = encodeURI(`http://localhost:3000/manage`) + const GITHUB_APP_CLIENTID = `Iv1.3392d0790b8f8334` + const authURL = `https://github.com/login/oauth/authorize?&client_id=${GITHUB_APP_CLIENTID}&redirect_uri=${callbackURL}` + + const { code } = req.query + if (!code) { + return res.redirect(authURL) + } + try { + const repositories = await getRepositoriesForUser(code) + const repositoriesWithTokens = await getRepositoryTokens( + repositories, + ) + return res.render('manage', { repositoriesWithTokens }) + } catch (error) { + if (error.message === 'bad_verification_code') { + return res.redirect(authURL) + } + + throw error + } + }), + ) app.get( '/results', asyncMiddleware(async (req, res) => { diff --git a/src/static/manage-styles.css b/src/static/manage-styles.css new file mode 100644 index 00000000..0a3ecccb --- /dev/null +++ b/src/static/manage-styles.css @@ -0,0 +1,106 @@ +@import url("https://fonts.googleapis.com/css?family=Roboto+Mono|Source+Sans+Pro:300,400,600"); +* { + -webkit-font-smoothing: antialiased; + -webkit-overflow-scrolling: touch; + -webkit-tap-highlight-color: rgba(0,0,0,0); + -webkit-text-size-adjust: none; + -webkit-touch-callout: none; + -webkit-box-sizing: border-box; + box-sizing: border-box; +} +html { + box-sizing: border-box; +} +*, *:before, *:after { + box-sizing: inherit; +} +html, body { + margin: 0; + padding: 0; +} +body { + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + color: #36454f; + font-family: 'Source Sans Pro', 'Helvetica Neue', Arial, sans-serif; + font-size: 15px; + letter-spacing: 0; + background: #f7f7f7; +} +.logo { + display: block; + margin: 50px auto; + text-align: center; +} +.container { + margin: 0 12px; +} +@media(min-width: 1000px) { + .container { + margin: 0 auto; + width: 70%; + } +} +.card { + text-align: center; + margin-bottom: 24px; + padding: 24px; + background: #FFF; + box-shadow: 0px 3px 5px 2px rgba(201, 214, 255, 0.15); +} + +.btn { + position: relative; + display: inline-block; + padding: 1.2em 2em; + text-decoration: none; + text-align: center; + cursor: pointer; + user-select: none; + color: white; +} + +.btn::before { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: #d54400; + border-radius: 4px; + transition: all .5s ease, transform .2s ease; + box-shadow: 0 2px 5px rgba(0, 0, 0, .2); +} + +.btn:hover::before { + opacity: 0.8; +} + +.btn::after { + position: relative; + display: inline-block; + content: attr(data-title); + transition: transform .2s ease; + font-weight: bold; + letter-spacing: .01em; +} + + +footer { + display: flex; + justify-content: center; + align-items: center; + text-align: center; + height: 80px; + margin: 24px; +} + +.footer { + color: #d54400; +} + +.footer a { + margin: 0 8px; +} + diff --git a/src/views/manage.mustache b/src/views/manage.mustache new file mode 100644 index 00000000..fd225b87 --- /dev/null +++ b/src/views/manage.mustache @@ -0,0 +1,32 @@ + + + + Manage | BundleWatch + + + + + +
+
+ +
+
+
+ {{#repositoriesWithTokens}} + {{repoFullName}} + + {{/repositoriesWithTokens}} +
+
+
+ +{{> partials/footer}} + + + diff --git a/src/views/partials/footer.mustache b/src/views/partials/footer.mustache new file mode 100644 index 00000000..b3d0fbd9 --- /dev/null +++ b/src/views/partials/footer.mustache @@ -0,0 +1,47 @@ + + + + + + + + diff --git a/src/views/results.mustache b/src/views/results.mustache index d6280c75..ac2c9992 100644 --- a/src/views/results.mustache +++ b/src/views/results.mustache @@ -4,8 +4,7 @@ - +
@@ -122,51 +121,7 @@
- - - - - - - diff --git a/src/views/setup-github.mustache b/src/views/setup-github.mustache index d9a4b028..ee0026fb 100644 --- a/src/views/setup-github.mustache +++ b/src/views/setup-github.mustache @@ -2,12 +2,9 @@ Setup GitHub | BundleWatch - + - -
@@ -39,51 +36,7 @@
- - - - - - - diff --git a/yarn.lock b/yarn.lock index 911ff270..5e61422a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3943,6 +3943,10 @@ moment@2.x.x, moment@^2.13.0: version "2.21.0" resolved "https://registry.yarnpkg.com/moment/-/moment-2.21.0.tgz#2a114b51d2a6ec9e6d83cf803f838a878d8a023a" +moxios@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/moxios/-/moxios-0.4.0.tgz#fc0da2c65477d725ca6b9679d58370ed0c52f53b" + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"