diff --git a/src/server_manager/base.webpack.js b/src/server_manager/base.webpack.js index b998881c2..8e0c1b3b9 100644 --- a/src/server_manager/base.webpack.js +++ b/src/server_manager/base.webpack.js @@ -63,6 +63,7 @@ exports.makeConfig = (options) => { resolve: {extensions: ['.tsx', '.ts', '.js']}, plugins: [ new webpack.DefinePlugin({ + 'outline.gcpAuthEnabled': JSON.stringify(process.env.GCP_AUTH_ENABLED === 'true'), // Hack to protect against @sentry/electron not having process.type defined. 'process.type': JSON.stringify('renderer'), // Statically link the Roboto font, rather than link to fonts.googleapis.com diff --git a/src/server_manager/electron_app/gcp_oauth.ts b/src/server_manager/electron_app/gcp_oauth.ts new file mode 100644 index 000000000..672e1b123 --- /dev/null +++ b/src/server_manager/electron_app/gcp_oauth.ts @@ -0,0 +1,135 @@ +// Copyright 2021 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as electron from 'electron'; +import * as express from 'express'; +import {OAuth2Client} from 'google-auth-library'; +import {AddressInfo} from 'net'; + +const OAUTH_CONFIG = { + project_id: 'outline-manager-oauth', + client_id: '946220775492-osi1dm2rhhpo4upm6qqfv9fiivv1qu6c.apps.googleusercontent.com', + scopes: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/cloud-platform', + ], +}; +const REDIRECT_PATH = '/gcp/oauth/callback'; + +function responseHtml(messageHtml: string): string { + return `${ + messageHtml}. You can close this window.`; +} + +/** + * Verifies that the access token has the required scopes. + * + * The access token may have missing scopes if the user denies access to any + * scopes in the OAuth flow. + * + * @param oAuthClient: The GCP OAuth 2.0 client. + * @param accessToken: The granted access token. + */ +async function verifyGrantedScopes( + oAuthClient: OAuth2Client, accessToken: string): Promise { + const getTokenInfoResponse = await oAuthClient.getTokenInfo(accessToken); + for (const requiredScope of OAUTH_CONFIG.scopes) { + const matchedScope = + getTokenInfoResponse.scopes.find((grantedScope) => grantedScope === requiredScope); + if (!matchedScope) { + return false; + } + } + return true; +} + +export function runOauth(): OauthSession { + // Start web server to handle OAuth callback + const app = express(); + const server = app.listen(); + const port = (server.address() as AddressInfo).port; + + // Open browser to OAuth URL + const oAuthClient = new OAuth2Client( + OAUTH_CONFIG.client_id, + null, + `http://localhost:${port}${REDIRECT_PATH}`, + ); + const oAuthUrl = oAuthClient.generateAuthUrl({ + access_type: 'offline', + scope: OAUTH_CONFIG.scopes, + }); + electron.shell.openExternal(oAuthUrl); + + // Handle OAuth redirect callback + let isCancelled = false; + const rejectWrapper = {reject: (error: Error) => {}}; + const tokenPromise = new Promise((resolve, reject) => { + rejectWrapper.reject = reject; + app.get(REDIRECT_PATH, async (request: express.Request, response: express.Response) => { + if (request.query.error) { + if (request.query.error === 'access_denied') { + isCancelled = true; + response.send(responseHtml('Authentication cancelled')); + reject(new Error('Authentication cancelled')); + } else { + response.send(responseHtml('Authentication failed')); + reject(new Error(`Authentication failed with error: ${request.query.error}`)); + } + } else { + try { + const getTokenResponse = await oAuthClient.getToken(request.query.code as string); + if (getTokenResponse.res.status / 100 === 2) { + const scopesValid = + await verifyGrantedScopes(oAuthClient, getTokenResponse.tokens.access_token); + if (!scopesValid) { + console.error( + 'Authentication failed with missing scope(s). Granted: ', + getTokenResponse.tokens.scope); + response.send(responseHtml('Authentication failed with missing scope(s)')); + reject(new Error('Authentication failed with missing scope(s)')); + } else if (!getTokenResponse.tokens.refresh_token) { + response.send(responseHtml('Authentication failed')); + reject(new Error('Authentication failed: Missing refresh token')); + } else { + response.send(responseHtml('Authentication successful')); + resolve(getTokenResponse.tokens.refresh_token); + } + } else { + response.send(responseHtml('Authentication failed')); + reject(new Error( + `Authentication failed with HTTP status code: ${getTokenResponse.res.status}`)); + } + } catch (error) { + response.send(responseHtml('Authentication failed')); + reject(new Error(`Authentication failed with error: ${request.query.error}`)); + } + } + server.close(); + }); + }); + + return { + result: tokenPromise, + isCancelled() { + return isCancelled; + }, + cancel() { + console.log('Session cancelled'); + isCancelled = true; + server.close(); + rejectWrapper.reject(new Error('Authentication cancelled')); + } + }; +} diff --git a/src/server_manager/electron_app/index.ts b/src/server_manager/electron_app/index.ts index dceb41b63..943098d60 100644 --- a/src/server_manager/electron_app/index.ts +++ b/src/server_manager/electron_app/index.ts @@ -126,7 +126,7 @@ function getWebAppUrl() { } if (debugMode) { queryParams.set('outlineDebugMode', 'true'); - console.log(`Enabling Outline debug mode`); + console.log('Enabling Outline debug mode'); } // Append arguments to URL if any. diff --git a/src/server_manager/electron_app/preload.ts b/src/server_manager/electron_app/preload.ts index bb73e81f9..426ee6de0 100644 --- a/src/server_manager/electron_app/preload.ts +++ b/src/server_manager/electron_app/preload.ts @@ -17,6 +17,7 @@ import {ipcRenderer} from 'electron'; import {URL} from 'url'; import * as digitalocean_oauth from './digitalocean_oauth'; +import * as gcp_oauth from './gcp_oauth'; import {redactManagerUrl} from './util'; // This file is run in the renderer process *before* nodeIntegration is disabled. @@ -64,6 +65,9 @@ if (sentryDsn) { // tslint:disable-next-line:no-any (window as any).runDigitalOceanOauth = digitalocean_oauth.runOauth; +// tslint:disable-next-line:no-any +(window as any).runGcpOauth = gcp_oauth.runOauth; + // tslint:disable-next-line:no-any (window as any).bringToFront = () => { return ipcRenderer.send('bring-to-front'); diff --git a/src/server_manager/messages/en.json b/src/server_manager/messages/en.json index 3a26ea7d7..8c3096283 100644 --- a/src/server_manager/messages/en.json +++ b/src/server_manager/messages/en.json @@ -41,6 +41,7 @@ "error-do-account-info": "Failed to get DigitalOcean account information", "error-do-auth": "Authentication with DigitalOcean failed", "error-do-regions": "Failed to get list of available regions", + "error-gcp-auth": "Authentication with Google Cloud Platform failed", "error-feedback": "Failed to submit feedback. Please try again.", "error-hostname-invalid": "Must be an IP address or valid hostname.", "error-key-add": "Failed to add key", @@ -98,6 +99,7 @@ "gcp-firewall-create-2": "Type 'outline' in the 'Target tags' field.", "gcp-firewall-create-3": "Type '0.0.0.0/0' in the 'Source IP ranges' field.", "gcp-firewall-create-4": "Select 'Allow all' under 'Protocols and ports'.", + "gcp-oauth-connect-title": "Sign in or create an account with Google Cloud Platform.", "gcp-name-your-project": "Name your project in the 'Project name' field.", "gcp-select-machine-type": "Select 'f1-micro' under 'Machine type'.", "gcp-select-networking": "Click 'Management, security, disks, networking, sole tenancy', then 'Networking'", diff --git a/src/server_manager/messages/master_messages.json b/src/server_manager/messages/master_messages.json index b6f798196..467fb1b5f 100644 --- a/src/server_manager/messages/master_messages.json +++ b/src/server_manager/messages/master_messages.json @@ -219,6 +219,10 @@ "message": "Failed to get list of available regions", "description": "This string appears in an error notification toast. It is shown when there is an error retrieving the regions available for server deployment." }, + "error_gcp_auth": { + "message": "Authentication with Google Cloud Platform failed", + "description": "This string appears in an error notification toast. It is shown when there is an error when logging in to the user's Google Cloud Platform account. Google Cloud Platform is a cloud server provider name and should not be translated." + }, "error_feedback": { "message": "Failed to submit feedback. Please try again.", "description": "This string appears in an error notification toast. It is shown when there is an error submitting the user's feedback." diff --git a/src/server_manager/model/accounts.ts b/src/server_manager/model/accounts.ts new file mode 100644 index 000000000..4bee8bf80 --- /dev/null +++ b/src/server_manager/model/accounts.ts @@ -0,0 +1,60 @@ +// Copyright 2021 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as digitalocean from './digitalocean'; +import * as gcp from './gcp'; + +export interface CloudAccounts { + /** + * Connects a DigitalOcean account. + * + * Only one DigitalOcean account can be connected at any given time. + * Subsequent calls to this method will overwrite any previously connected + * DigtialOcean account. + * + * @param accessToken: The DigitalOcean access token. + */ + connectDigitalOceanAccount(accessToken: string): digitalocean.Account; + + /** + * Connects a Google Cloud Platform account. + * + * Only one Google Cloud Platform account can be connected at any given time. + * Subsequent calls to this method will overwrite any previously connected + * Google Cloud Platform account. + * + * @param refreshToken: The GCP refresh token. + */ + connectGcpAccount(refreshToken: string): gcp.Account; + + /** + * Disconnects the DigitalOcean account. + */ + disconnectDigitalOceanAccount(): void; + + /** + * Disconnects the Google Cloud Platform account. + */ + disconnectGcpAccount(): void; + + /** + * @returns the connected DigitalOcean account (or null if none exists). + */ + getDigitalOceanAccount(): digitalocean.Account; + + /** + * @returns the connected Google Cloud Platform account (or null if none exists). + */ + getGcpAccount(): gcp.Account; +} diff --git a/src/server_manager/model/gcp.ts b/src/server_manager/model/gcp.ts new file mode 100644 index 000000000..68f70ce17 --- /dev/null +++ b/src/server_manager/model/gcp.ts @@ -0,0 +1,20 @@ +// Copyright 2021 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// TODO: This is just a stub atm and will need to define and implement the rest +// of the functionality. +export interface Account { + // Returns a user-friendly name associated with the account. + getName(): Promise; +} diff --git a/src/server_manager/package.json b/src/server_manager/package.json index 6da4e5603..591ef15a5 100644 --- a/src/server_manager/package.json +++ b/src/server_manager/package.json @@ -46,6 +46,7 @@ "electron-updater": "^4.1.2", "eventemitter3": "^2.0.3", "express": "^4.16.3", + "google-auth-library": "^7.0.2", "intl-messageformat": "^7", "jsonic": "^0.3.1", "lit-element": "^2.3.1", diff --git a/src/server_manager/types/preload.d.ts b/src/server_manager/types/preload.d.ts index ff10df6a8..79a943cc6 100644 --- a/src/server_manager/types/preload.d.ts +++ b/src/server_manager/types/preload.d.ts @@ -30,4 +30,6 @@ interface OauthSession { declare function runDigitalOceanOauth(): OauthSession; +declare function runGcpOauth(): OauthSession; + declare function bringToFront(): void; diff --git a/src/server_manager/web_app/app.spec.ts b/src/server_manager/web_app/app.spec.ts index e40c86746..611147c41 100644 --- a/src/server_manager/web_app/app.spec.ts +++ b/src/server_manager/web_app/app.spec.ts @@ -14,12 +14,11 @@ import './ui_components/app-root.js'; -import {InMemoryStorage} from '../infrastructure/memory_storage'; -import * as digitalocean from '../model/digitalocean'; +import * as accounts from '../model/accounts'; import * as server from '../model/server'; import {App, LAST_DISPLAYED_SERVER_STORAGE_KEY} from './app'; -import {CloudAccounts} from './cloud_accounts'; +import {FakeCloudAccounts, FakeDigitalOceanAccount, FakeManualServerRepository} from './testing/models'; import {AppRoot} from './ui_components/app-root'; @@ -65,7 +64,7 @@ describe('App', () => { // Create fake servers and simulate their metadata being cached before creating the app. const fakeAccount = new FakeDigitalOceanAccount(); await fakeAccount.createServer('fake-managed-server-id'); - const cloudAccounts = makeCloudAccountsWithDoAccount(fakeAccount); + const cloudAccounts = new FakeCloudAccounts(fakeAccount); const manualServerRepo = new FakeManualServerRepository(); await manualServerRepo.addServer({certSha256: 'cert', apiUrl: 'fake-manual-server-api-url-1'}); @@ -110,7 +109,7 @@ describe('App', () => { it('shows progress screen once DigitalOcean droplets are created', async () => { // Start the app with a fake DigitalOcean token. const appRoot = document.getElementById('appRoot') as unknown as AppRoot; - const cloudAccounts = makeCloudAccountsWithDoAccount(new FakeDigitalOceanAccount()); + const cloudAccounts = new FakeCloudAccounts(new FakeDigitalOceanAccount()); const app = createTestApp(appRoot, cloudAccounts); await app.start(); await app.createDigitalOceanServer('fakeRegion'); @@ -123,7 +122,7 @@ describe('App', () => { const appRoot = document.getElementById('appRoot') as unknown as AppRoot; const fakeAccount = new FakeDigitalOceanAccount(); const server = await fakeAccount.createServer(Math.random().toString()); - const cloudAccounts = makeCloudAccountsWithDoAccount(fakeAccount); + const cloudAccounts = new FakeCloudAccounts(fakeAccount); const app = createTestApp(appRoot, cloudAccounts, null); // Sets last displayed server. localStorage.setItem(LAST_DISPLAYED_SERVER_STORAGE_KEY, server.getId()); @@ -133,184 +132,15 @@ describe('App', () => { }); }); -function makeCloudAccountsWithDoAccount(fakeAccount: FakeDigitalOceanAccount) { - const fakeDigitalOceanAccountFactory = (token: string) => fakeAccount; - const cloudAccounts = new CloudAccounts(fakeDigitalOceanAccountFactory, new InMemoryStorage()); - cloudAccounts.connectDigitalOceanAccount('fake-access-token'); - return cloudAccounts; -} - function createTestApp( - appRoot: AppRoot, cloudAccounts?: CloudAccounts, + appRoot: AppRoot, cloudAccounts?: accounts.CloudAccounts, manualServerRepo?: server.ManualServerRepository) { const VERSION = '0.0.1'; if (!cloudAccounts) { - cloudAccounts = - new CloudAccounts((token: string) => new FakeDigitalOceanAccount(), new InMemoryStorage()); + cloudAccounts = new FakeCloudAccounts(); } if (!manualServerRepo) { manualServerRepo = new FakeManualServerRepository(); } return new App(appRoot, VERSION, manualServerRepo, cloudAccounts); } - -class FakeServer implements server.Server { - private name = 'serverName'; - private metricsId: string; - private metricsEnabled = false; - apiUrl: string; - constructor(protected id: string) { - this.metricsId = Math.random().toString(); - } - getId() { - return this.id; - } - getName() { - return this.name; - } - setName(name: string) { - this.name = name; - return Promise.resolve(); - } - getVersion() { - return '1.2.3'; - } - listAccessKeys() { - return Promise.resolve([]); - } - getMetricsEnabled() { - return this.metricsEnabled; - } - setMetricsEnabled(metricsEnabled: boolean) { - this.metricsEnabled = metricsEnabled; - return Promise.resolve(); - } - getMetricsId() { - return this.metricsId; - } - isHealthy() { - return Promise.resolve(true); - } - getCreatedDate() { - return new Date(); - } - getDataUsage() { - return Promise.resolve(new Map()); - } - addAccessKey() { - return Promise.reject(new Error('FakeServer.addAccessKey not implemented')); - } - renameAccessKey(accessKeyId: server.AccessKeyId, name: string) { - return Promise.reject(new Error('FakeServer.renameAccessKey not implemented')); - } - removeAccessKey(accessKeyId: server.AccessKeyId) { - return Promise.reject(new Error('FakeServer.removeAccessKey not implemented')); - } - setHostnameForAccessKeys(hostname: string) { - return Promise.reject(new Error('FakeServer.setHostname not implemented')); - } - getHostnameForAccessKeys() { - return 'fake-server'; - } - getManagementApiUrl() { - return this.apiUrl || Math.random().toString(); - } - getPortForNewAccessKeys(): number|undefined { - return undefined; - } - setPortForNewAccessKeys(): Promise { - return Promise.reject(new Error('FakeServer.setPortForNewAccessKeys not implemented')); - } - setAccessKeyDataLimit(accessKeyId: string, limit: server.DataLimit): Promise { - return Promise.reject(new Error('FakeServer.setAccessKeyDataLimit not implemented')); - } - removeAccessKeyDataLimit(accessKeyId: string): Promise { - return Promise.reject(new Error('FakeServer.removeAccessKeyDataLimit not implemented')); - } - setDefaultDataLimit(limit: server.DataLimit): Promise { - return Promise.reject(new Error('FakeServer.setDefaultDataLimit not implemented')); - } - removeDefaultDataLimit(): Promise { - return Promise.resolve(); - } - getDefaultDataLimit(): server.DataLimit|undefined { - return undefined; - } -} - -class FakeManualServer extends FakeServer implements server.ManualServer { - constructor(public manualServerConfig: server.ManualServerConfig) { - super(manualServerConfig.apiUrl); - } - getManagementApiUrl() { - return this.manualServerConfig.apiUrl; - } - forget() { - return Promise.reject(new Error('FakeManualServer.forget not implemented')); - } - getCertificateFingerprint() { - return this.manualServerConfig.certSha256; - } -} - -class FakeManualServerRepository implements server.ManualServerRepository { - private servers: server.ManualServer[] = []; - - addServer(config: server.ManualServerConfig) { - const newServer = new FakeManualServer(config); - this.servers.push(newServer); - return Promise.resolve(newServer); - } - - findServer(config: server.ManualServerConfig) { - return this.servers.find(server => server.getManagementApiUrl() === config.apiUrl); - } - - listServers() { - return Promise.resolve(this.servers); - } -} - -class FakeManagedServer extends FakeServer implements server.ManagedServer { - constructor(id: string, private isInstalled = true) { - super(id); - } - waitOnInstall() { - // Return a promise which does not yet fulfill, to simulate long - // shadowbox install time. - return new Promise((fulfill, reject) => {}); - } - getHost() { - return { - getMonthlyOutboundTransferLimit: () => ({terabytes: 1}), - getMonthlyCost: () => ({usd: 5}), - getRegionId: () => 'fake-region', - delete: () => Promise.resolve(), - getHostId: () => 'fake-host-id', - }; - } - isInstallCompleted() { - return this.isInstalled; - } -} - -class FakeDigitalOceanAccount implements digitalocean.Account { - private servers: server.ManagedServer[] = []; - async getName(): Promise { - return 'fake-name'; - } - async getStatus(): Promise { - return digitalocean.Status.ACTIVE; - } - listServers() { - return Promise.resolve(this.servers); - } - getRegionMap() { - return Promise.resolve({'fake': ['fake1', 'fake2']}); - } - createServer(id = Math.random().toString()) { - const newServer = new FakeManagedServer(id, false); - this.servers.push(newServer); - return Promise.resolve(newServer); - } -} diff --git a/src/server_manager/web_app/app.ts b/src/server_manager/web_app/app.ts index 7b068a104..781fdaa70 100644 --- a/src/server_manager/web_app/app.ts +++ b/src/server_manager/web_app/app.ts @@ -18,10 +18,11 @@ import * as semver from 'semver'; import * as digitalocean_api from '../cloud/digitalocean_api'; import * as errors from '../infrastructure/errors'; import {sleep} from '../infrastructure/sleep'; +import * as accounts from '../model/accounts'; import * as digitalocean from '../model/digitalocean'; +import * as gcp from '../model/gcp'; import * as server from '../model/server'; -import {CloudAccounts} from './cloud_accounts'; import {bytesToDisplayDataAmount, DisplayDataAmount, displayDataAmountToBytes,} from './data_formatting'; import * as digitalocean_server from './digitalocean_server'; import {parseManualServerConfig} from './management_urls'; @@ -123,13 +124,14 @@ function isManualServer(testServer: server.Server): testServer is server.ManualS export class App { private digitalOceanAccount: digitalocean.Account; + private gcpAccount: gcp.Account; private selectedServer: server.Server; private idServerMap = new Map(); constructor( private appRoot: AppRoot, private readonly version: string, private manualServerRepository: server.ManualServerRepository, - private cloudAccounts: CloudAccounts) { + private cloudAccounts: accounts.CloudAccounts) { appRoot.setAttribute('outline-version', this.version); appRoot.addEventListener('ConnectDigitalOceanAccountRequested', (event: CustomEvent) => { @@ -144,6 +146,12 @@ export class App { this.handleConnectDigitalOceanAccountRequest(); } }); + appRoot.addEventListener( + 'ConnectGcpAccountRequested', + async (event: CustomEvent) => this.handleConnectGcpAccountRequest()); + appRoot.addEventListener( + 'CreateGcpServerRequested', + async (event: CustomEvent) => console.log('Received CreateGcpServerRequested event')); appRoot.addEventListener('SignOutRequested', (event: CustomEvent) => { this.disconnectDigitalOceanAccount(); this.showIntro(); @@ -506,7 +514,7 @@ export class App { }); }; - // Runs the oauth flow and returns the API access token. + // Runs the DigitalOcean OAuth flow and returns the API access token. // Throws CANCELLED_ERROR on cancellation, or the error in case of failure. private async runDigitalOceanOauthFlow(): Promise { const oauth = runDigitalOceanOauth(); @@ -533,6 +541,28 @@ export class App { } } + // Runs the GCP OAuth flow and returns the API refresh token (which can be + // exchanged for an access token). + // Throws CANCELLED_ERROR on cancellation, or the error in case of failure. + private async runGcpOauthFlow(): Promise { + const oauth = runGcpOauth(); + const handleOauthFlowCancelled = () => { + oauth.cancel(); + this.disconnectGcpAccount(); + this.showIntro(); + }; + this.appRoot.getAndShowGcpOauthFlow(handleOauthFlowCancelled); + try { + return await oauth.result; + } catch (error) { + if (oauth.isCancelled()) { + throw CANCELLED_ERROR; + } else { + throw error; + } + } + } + private async handleConnectDigitalOceanAccountRequest(): Promise { let digitalOceanAccount: digitalocean.Account; try { @@ -556,7 +586,26 @@ export class App { } } - // Clears the credentials and returns to the intro screen. + private async handleConnectGcpAccountRequest(): Promise { + try { + const refreshToken = await this.runGcpOauthFlow(); + this.gcpAccount = this.cloudAccounts.connectGcpAccount(refreshToken); + } catch (error) { + this.disconnectGcpAccount(); + this.showIntro(); + bringToFront(); + if (error !== CANCELLED_ERROR) { + console.error(`GCP authentication failed: ${error}`); + this.appRoot.showError(this.appRoot.localize('error-gcp-auth')); + } + return; + } + + this.appRoot.gcpAccountName = await this.gcpAccount.getName(); + this.showIntro(); + } + + // Clears the DigitalOcean credentials and returns to the intro screen. private disconnectDigitalOceanAccount(): void { this.cloudAccounts.disconnectDigitalOceanAccount(); this.digitalOceanAccount = null; @@ -568,6 +617,13 @@ export class App { this.appRoot.digitalOceanAccountName = ''; } + // Clears the GCP credentials and returns to the intro screen. + private disconnectGcpAccount(): void { + this.cloudAccounts.disconnectGcpAccount(); + this.gcpAccount = null; + this.appRoot.gcpAccountName = ''; + } + // Opens the screen to create a server. private async showDigitalOceanCreateServer(digitalOceanAccount: digitalocean.Account): Promise { diff --git a/src/server_manager/web_app/cloud_accounts.spec.ts b/src/server_manager/web_app/cloud_accounts.spec.ts new file mode 100644 index 000000000..c272a48d1 --- /dev/null +++ b/src/server_manager/web_app/cloud_accounts.spec.ts @@ -0,0 +1,109 @@ +// Copyright 2021 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {InMemoryStorage} from '../infrastructure/memory_storage'; + +import {CloudAccounts} from './cloud_accounts'; + +describe('CloudAccounts', () => { + it('get account methods return null when no cloud accounts are connected', () => { + const cloudAccounts = createCloudAccount(); + expect(cloudAccounts.getDigitalOceanAccount()).toBeNull(); + expect(cloudAccounts.getGcpAccount()).toBeNull(); + }); + + it('load connects account that exist in local storage', () => { + const storage = createInMemoryStorage('fake-access-token', 'fake-refresh-token'); + const cloudAccounts = createCloudAccount(storage); + expect(cloudAccounts.getDigitalOceanAccount()).not.toBeNull(); + expect(cloudAccounts.getGcpAccount()).not.toBeNull(); + }); + + it('connects accounts when connect methods are invoked', () => { + const cloudAccounts = createCloudAccount(); + + expect(cloudAccounts.getDigitalOceanAccount()).toBeNull(); + cloudAccounts.connectDigitalOceanAccount('fake-access-token'); + expect(cloudAccounts.getDigitalOceanAccount()).not.toBeNull(); + + expect(cloudAccounts.getGcpAccount()).toBeNull(); + cloudAccounts.connectGcpAccount('fake-access-token'); + expect(cloudAccounts.getGcpAccount()).not.toBeNull(); + }); + + it('removes account when disconnect is invoked', () => { + const storage = createInMemoryStorage('fake-access-token', 'fake-refresh-token'); + const cloudAccounts = createCloudAccount(storage); + + expect(cloudAccounts.getDigitalOceanAccount()).not.toBeNull(); + cloudAccounts.disconnectDigitalOceanAccount(); + expect(cloudAccounts.getDigitalOceanAccount()).toBeNull(); + + expect(cloudAccounts.getGcpAccount()).not.toBeNull(); + cloudAccounts.disconnectGcpAccount(); + expect(cloudAccounts.getGcpAccount()).toBeNull(); + }); + + it('functional noop on calling disconnect when accounts are not connected', () => { + const cloudAccounts = createCloudAccount(); + + expect(cloudAccounts.getDigitalOceanAccount()).toBeNull(); + cloudAccounts.disconnectDigitalOceanAccount(); + expect(cloudAccounts.getDigitalOceanAccount()).toBeNull(); + + expect(cloudAccounts.getGcpAccount()).toBeNull(); + cloudAccounts.disconnectGcpAccount(); + expect(cloudAccounts.getGcpAccount()).toBeNull(); + }); + + it('migrates existing legacy DigitalOcean access token on load', () => { + const storage = new InMemoryStorage(); + storage.setItem('LastDOToken', 'legacy-digitalocean-access-token'); + const cloudAccounts = createCloudAccount(storage); + + expect(cloudAccounts.getDigitalOceanAccount()).not.toBeNull(); + }); + + it('updates legacy DigitalOcean access token when account reconnected', () => { + const storage = new InMemoryStorage(); + storage.setItem('LastDOToken', 'legacy-digitalocean-access-token'); + const cloudAccounts = createCloudAccount(storage); + + expect(storage.getItem('LastDOToken')).toEqual('legacy-digitalocean-access-token'); + cloudAccounts.connectDigitalOceanAccount('new-digitalocean-access-token'); + expect(storage.getItem('LastDOToken')).toEqual('new-digitalocean-access-token'); + }); +}); + +function createInMemoryStorage( + digitalOceanAccessToken?: string, gcpRefreshToken?: string): Storage { + const storage = new InMemoryStorage(); + if (digitalOceanAccessToken) { + storage.setItem( + 'accounts.digitalocean', JSON.stringify({accessToken: digitalOceanAccessToken})); + } + if (gcpRefreshToken) { + storage.setItem('accounts.gcp', JSON.stringify({refreshToken: gcpRefreshToken})); + } + return storage; +} + +function createCloudAccount(storage = createInMemoryStorage()): CloudAccounts { + const shadowboxSettings = { + imageId: 'fake-image-id', + metricsUrl: 'fake-metrics-url', + sentryApiUrl: 'fake-sentry-api', + }; + return new CloudAccounts(shadowboxSettings, true, storage); +} diff --git a/src/server_manager/web_app/cloud_accounts.ts b/src/server_manager/web_app/cloud_accounts.ts index 5deeca0e7..6f3104914 100644 --- a/src/server_manager/web_app/cloud_accounts.ts +++ b/src/server_manager/web_app/cloud_accounts.ts @@ -12,28 +12,137 @@ // See the License for the specific language governing permissions and // limitations under the License. +import * as accounts from '../model/accounts'; import * as digitalocean from '../model/digitalocean'; +import * as gcp from '../model/gcp'; +import {DigitalOceanAccount, ShadowboxSettings} from './digitalocean_account'; +import {GcpAccount} from './gcp_account'; -type DigitalOceanAccountFactory = (accessToken: string) => digitalocean.Account; +type DigitalOceanAccountJson = { + accessToken: string +}; -export class CloudAccounts { - private readonly DIGITALOCEAN_TOKEN_STORAGE_KEY = 'LastDOToken'; +type GcpAccountJson = { + refreshToken: string, +}; + +/** + * Manages connected cloud provider accounts. + */ +export class CloudAccounts implements accounts.CloudAccounts { + private readonly LEGACY_DIGITALOCEAN_STORAGE_KEY = 'LastDOToken'; + private readonly DIGITALOCEAN_ACCOUNT_STORAGE_KEY = 'accounts.digitalocean'; + private readonly GCP_ACCOUNT_STORAGE_KEY = 'accounts.gcp'; + + private digitalOceanAccount: DigitalOceanAccount = null; + private gcpAccount: GcpAccount = null; constructor( - private digitalOceanAccountFactory: DigitalOceanAccountFactory, - private storage = localStorage) { } + private shadowboxSettings: ShadowboxSettings, private isDebugMode: boolean, + private storage = localStorage) { + this.load(); + } + /** See {@link CloudAccounts#connectDigitalOceanAccount} */ connectDigitalOceanAccount(accessToken: string): digitalocean.Account { - this.storage.setItem(this.DIGITALOCEAN_TOKEN_STORAGE_KEY, accessToken); - return this.digitalOceanAccountFactory(accessToken); + this.digitalOceanAccount = this.createDigitalOceanAccount(accessToken); + this.save(); + return this.digitalOceanAccount; } + /** See {@link CloudAccounts#connectGcpAccount} */ + connectGcpAccount(refreshToken: string): gcp.Account { + this.gcpAccount = this.createGcpAccount(refreshToken); + this.save(); + return this.gcpAccount; + } + + /** See {@link CloudAccounts#disconnectDigitalOceanAccount} */ disconnectDigitalOceanAccount(): void { - this.storage.removeItem(this.DIGITALOCEAN_TOKEN_STORAGE_KEY); + this.digitalOceanAccount = null; + this.save(); } + /** See {@link CloudAccounts#disconnectGcpAccount} */ + disconnectGcpAccount(): void { + this.gcpAccount = null; + this.save(); + } + + /** See {@link CloudAccounts#getDigitalOceanAccount} */ getDigitalOceanAccount(): digitalocean.Account { - const accessToken = this.storage.getItem(this.DIGITALOCEAN_TOKEN_STORAGE_KEY); - return accessToken ? this.digitalOceanAccountFactory(accessToken) : null; + return this.digitalOceanAccount; + } + + /** See {@link CloudAccounts#getGcpAccount} */ + getGcpAccount(): gcp.Account { + return this.gcpAccount; + } + + /** Loads the saved cloud accounts from disk. */ + private load(): void { + const digitalOceanAccountJsonString = + this.storage.getItem(this.DIGITALOCEAN_ACCOUNT_STORAGE_KEY); + if (!digitalOceanAccountJsonString) { + const digitalOceanToken = this.loadLegacyDigitalOceanToken(); + if (digitalOceanToken) { + this.digitalOceanAccount = this.createDigitalOceanAccount(digitalOceanToken); + this.save(); + } + } else { + const digitalOceanAccountJson: DigitalOceanAccountJson = + JSON.parse(digitalOceanAccountJsonString); + this.digitalOceanAccount = + this.createDigitalOceanAccount(digitalOceanAccountJson.accessToken); + } + + const gcpAccountJsonString = this.storage.getItem(this.GCP_ACCOUNT_STORAGE_KEY); + if (gcpAccountJsonString) { + const gcpAccountJson: GcpAccountJson = + JSON.parse(this.storage.getItem(this.GCP_ACCOUNT_STORAGE_KEY)); + this.gcpAccount = this.createGcpAccount(gcpAccountJson.refreshToken); + } + } + + /** Loads legacy DigitalOcean access token. */ + private loadLegacyDigitalOceanToken(): string { + return this.storage.getItem(this.LEGACY_DIGITALOCEAN_STORAGE_KEY); + } + + /** Replace the legacy DigitalOcean access token. */ + private saveLegacyDigitalOceanToken(accessToken?: string): void { + if (accessToken) { + this.storage.setItem(this.LEGACY_DIGITALOCEAN_STORAGE_KEY, accessToken); + } else { + this.storage.removeItem(this.LEGACY_DIGITALOCEAN_STORAGE_KEY); + } + } + + private createDigitalOceanAccount(accessToken: string): DigitalOceanAccount { + return new DigitalOceanAccount(accessToken, this.shadowboxSettings, this.isDebugMode); + } + + private createGcpAccount(refreshToken: string): GcpAccount { + return new GcpAccount(refreshToken); + } + + private save(): void { + if (this.digitalOceanAccount) { + const accessToken = this.digitalOceanAccount.getAccessToken(); + const digitalOceanAccountJson: DigitalOceanAccountJson = {accessToken}; + this.storage.setItem( + this.DIGITALOCEAN_ACCOUNT_STORAGE_KEY, JSON.stringify(digitalOceanAccountJson)); + this.saveLegacyDigitalOceanToken(accessToken); + } else { + this.storage.removeItem(this.DIGITALOCEAN_ACCOUNT_STORAGE_KEY); + this.saveLegacyDigitalOceanToken(null); + } + if (this.gcpAccount) { + const refreshToken = this.gcpAccount.getRefreshToken(); + const gcpAccountJson: GcpAccountJson = {refreshToken}; + this.storage.setItem(this.GCP_ACCOUNT_STORAGE_KEY, JSON.stringify(gcpAccountJson)); + } else { + this.storage.removeItem(this.GCP_ACCOUNT_STORAGE_KEY); + } } } diff --git a/src/server_manager/web_app/digitalocean_account.ts b/src/server_manager/web_app/digitalocean_account.ts index 198122b35..a4624f16b 100644 --- a/src/server_manager/web_app/digitalocean_account.ts +++ b/src/server_manager/web_app/digitalocean_account.ts @@ -12,12 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {DigitalOceanSession, DropletInfo} from "../cloud/digitalocean_api"; -import {DigitalOceanServer, GetCityId} from "./digitalocean_server"; -import * as server from "../model/server"; -import * as crypto from "../infrastructure/crypto"; -import * as digitalocean from "../model/digitalocean"; -import * as do_install_script from "../install_scripts/do_install_script"; +import {DigitalOceanSession, DropletInfo, RestApiSession} from '../cloud/digitalocean_api'; +import * as crypto from '../infrastructure/crypto'; +import * as do_install_script from '../install_scripts/do_install_script'; +import * as digitalocean from '../model/digitalocean'; +import * as server from '../model/server'; + +import {DigitalOceanServer, GetCityId} from './digitalocean_server'; // Tag used to mark Shadowbox Droplets. const SHADOWBOX_TAG = 'shadowbox'; @@ -31,11 +32,14 @@ export interface ShadowboxSettings { } export class DigitalOceanAccount implements digitalocean.Account { + private readonly digitalOcean: DigitalOceanSession; private servers: DigitalOceanServer[] = []; constructor( - private digitalOcean: DigitalOceanSession, private shadowboxSettings: ShadowboxSettings, - private debugMode: boolean) {} + private accessToken: string, private shadowboxSettings: ShadowboxSettings, + private debugMode: boolean) { + this.digitalOcean = new RestApiSession(accessToken); + } async getName(): Promise { return (await this.digitalOcean.getAccount())?.email; @@ -111,6 +115,10 @@ export class DigitalOceanAccount implements digitalocean.Account { }); } + getAccessToken(): string { + return this.accessToken; + } + // Creates a DigitalOceanServer object and adds it to the in-memory server list. private createDigitalOceanServer(digitalOcean: DigitalOceanSession, dropletInfo: DropletInfo) { const server = new DigitalOceanServer(digitalOcean, dropletInfo); diff --git a/src/server_manager/web_app/gallery_app/main.ts b/src/server_manager/web_app/gallery_app/main.ts index f32b38b09..3c9f6221d 100644 --- a/src/server_manager/web_app/gallery_app/main.ts +++ b/src/server_manager/web_app/gallery_app/main.ts @@ -14,6 +14,7 @@ import '../ui_components/outline-about-dialog'; import '../ui_components/outline-do-oauth-step'; +import '../ui_components/outline-gcp-oauth-step'; import '../ui_components/outline-feedback-dialog'; import '../ui_components/outline-share-dialog'; import '../ui_components/outline-sort-span'; @@ -158,6 +159,11 @@ export class TestApp extends LitElement {

outline-do-oauth-step

+ +
+

outline-gcp-oauth-step

+ +

outline-feedback-dialog

diff --git a/src/server_manager/web_app/gcp_account.ts b/src/server_manager/web_app/gcp_account.ts new file mode 100644 index 000000000..0061222ad --- /dev/null +++ b/src/server_manager/web_app/gcp_account.ts @@ -0,0 +1,27 @@ +// Copyright 2021 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as gcp from '../model/gcp'; + +export class GcpAccount implements gcp.Account { + constructor(private refreshToken: string) {} + + async getName(): Promise { + return 'placeholder'; + } + + getRefreshToken(): string { + return this.refreshToken; + } +} diff --git a/src/server_manager/web_app/main.ts b/src/server_manager/web_app/main.ts index 4b90237f9..fc7a5f461 100644 --- a/src/server_manager/web_app/main.ts +++ b/src/server_manager/web_app/main.ts @@ -14,12 +14,10 @@ import './ui_components/app-root.js'; -import * as digitalocean_api from '../cloud/digitalocean_api'; import * as i18n from '../infrastructure/i18n'; import {App} from './app'; import {CloudAccounts} from './cloud_accounts'; -import {DigitalOceanAccount} from './digitalocean_account'; import {ManualServerRepository} from './manual_server'; import {AppRoot} from './ui_components/app-root.js'; @@ -106,12 +104,7 @@ document.addEventListener('WebComponentsReady', () => { watchtowerRefreshSeconds: shadowboxImageId ? 30 : undefined, }; - // Set DigitalOcean server repository parameters. - const digitalOceanAccountFactory = (accessToken: string) => { - const session = new digitalocean_api.RestApiSession(accessToken); - return new DigitalOceanAccount(session, shadowboxSettings, debugMode); - }; - const cloudAccounts = new CloudAccounts(digitalOceanAccountFactory); + const cloudAccounts = new CloudAccounts(shadowboxSettings, debugMode); // Create and start the app. const language = getLanguageToUse(); diff --git a/src/server_manager/web_app/testing/models.ts b/src/server_manager/web_app/testing/models.ts new file mode 100644 index 000000000..a2c040a2a --- /dev/null +++ b/src/server_manager/web_app/testing/models.ts @@ -0,0 +1,228 @@ +// Copyright 2021 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as accounts from '../../model/accounts'; +import * as digitalocean from '../../model/digitalocean'; +import * as gcp from '../../model/gcp'; +import * as server from '../../model/server'; + +export class FakeDigitalOceanAccount implements digitalocean.Account { + private servers: server.ManagedServer[] = []; + + constructor(private accessToken = 'fake-access-token') {} + + async getName(): Promise { + return 'fake-digitalocean-account-name'; + } + async getStatus(): Promise { + return digitalocean.Status.ACTIVE; + } + listServers() { + return Promise.resolve(this.servers); + } + getRegionMap() { + return Promise.resolve({'fake': ['fake1', 'fake2']}); + } + createServer(id: string) { + const newServer = new FakeManagedServer(id, false); + this.servers.push(newServer); + return Promise.resolve(newServer); + } + getAccessToken(): string { + return this.accessToken; + } +} + +export class FakeGcpAccount implements gcp.Account { + constructor(private refreshToken = 'fake-access-token') {} + + async getName(): Promise { + return 'fake-gcp-account-name'; + } + getRefreshToken(): string { + return this.refreshToken; + } +} + +export class FakeServer implements server.Server { + private name = 'serverName'; + private metricsId: string; + private metricsEnabled = false; + apiUrl: string; + constructor(protected id: string) { + this.metricsId = Math.random().toString(); + } + getId() { + return this.id; + } + getName() { + return this.name; + } + setName(name: string) { + this.name = name; + return Promise.resolve(); + } + getVersion() { + return '1.2.3'; + } + listAccessKeys() { + return Promise.resolve([]); + } + getMetricsEnabled() { + return this.metricsEnabled; + } + setMetricsEnabled(metricsEnabled: boolean) { + this.metricsEnabled = metricsEnabled; + return Promise.resolve(); + } + getMetricsId() { + return this.metricsId; + } + isHealthy() { + return Promise.resolve(true); + } + getCreatedDate() { + return new Date(); + } + getDataUsage() { + return Promise.resolve(new Map()); + } + addAccessKey() { + return Promise.reject(new Error('FakeServer.addAccessKey not implemented')); + } + renameAccessKey(accessKeyId: server.AccessKeyId, name: string) { + return Promise.reject(new Error('FakeServer.renameAccessKey not implemented')); + } + removeAccessKey(accessKeyId: server.AccessKeyId) { + return Promise.reject(new Error('FakeServer.removeAccessKey not implemented')); + } + setHostnameForAccessKeys(hostname: string) { + return Promise.reject(new Error('FakeServer.setHostname not implemented')); + } + getHostnameForAccessKeys() { + return 'fake-server'; + } + getManagementApiUrl() { + return this.apiUrl || Math.random().toString(); + } + getPortForNewAccessKeys(): number|undefined { + return undefined; + } + setPortForNewAccessKeys(): Promise { + return Promise.reject(new Error('FakeServer.setPortForNewAccessKeys not implemented')); + } + setAccessKeyDataLimit(accessKeyId: string, limit: server.DataLimit): Promise { + return Promise.reject(new Error('FakeServer.setAccessKeyDataLimit not implemented')); + } + removeAccessKeyDataLimit(accessKeyId: string): Promise { + return Promise.reject(new Error('FakeServer.removeAccessKeyDataLimit not implemented')); + } + setDefaultDataLimit(limit: server.DataLimit): Promise { + return Promise.reject(new Error('FakeServer.setDefaultDataLimit not implemented')); + } + removeDefaultDataLimit(): Promise { + return Promise.resolve(); + } + getDefaultDataLimit(): server.DataLimit|undefined { + return undefined; + } +} + +export class FakeManualServer extends FakeServer implements server.ManualServer { + constructor(public manualServerConfig: server.ManualServerConfig) { + super(manualServerConfig.apiUrl); + } + getManagementApiUrl() { + return this.manualServerConfig.apiUrl; + } + forget() { + return Promise.reject(new Error('FakeManualServer.forget not implemented')); + } + getCertificateFingerprint() { + return this.manualServerConfig.certSha256; + } +} + +export class FakeManualServerRepository implements server.ManualServerRepository { + private servers: server.ManualServer[] = []; + + addServer(config: server.ManualServerConfig) { + const newServer = new FakeManualServer(config); + this.servers.push(newServer); + return Promise.resolve(newServer); + } + + findServer(config: server.ManualServerConfig) { + return this.servers.find(server => server.getManagementApiUrl() === config.apiUrl); + } + + listServers() { + return Promise.resolve(this.servers); + } +} + +export class FakeManagedServer extends FakeServer implements server.ManagedServer { + constructor(id: string, private isInstalled = true) { + super(id); + } + waitOnInstall() { + // Return a promise which does not yet fulfill, to simulate long + // shadowbox install time. + return new Promise((fulfill, reject) => {}); + } + getHost() { + return { + getMonthlyOutboundTransferLimit: () => ({terabytes: 1}), + getMonthlyCost: () => ({usd: 5}), + getRegionId: () => 'fake-region', + delete: () => Promise.resolve(), + getHostId: () => 'fake-host-id', + }; + } + isInstallCompleted() { + return this.isInstalled; + } +} + +export class FakeCloudAccounts implements accounts.CloudAccounts { + constructor( + private digitalOceanAccount: digitalocean.Account = null, + private gcpAccount: gcp.Account = null) {} + + connectDigitalOceanAccount(accessToken: string): digitalocean.Account { + this.digitalOceanAccount = new FakeDigitalOceanAccount(accessToken); + return this.digitalOceanAccount; + } + + connectGcpAccount(refreshToken: string): gcp.Account { + this.gcpAccount = new FakeGcpAccount(refreshToken); + return this.gcpAccount; + } + + disconnectDigitalOceanAccount(): void { + this.digitalOceanAccount = null; + } + + disconnectGcpAccount(): void { + this.gcpAccount = null; + } + + getDigitalOceanAccount(): digitalocean.Account { + return this.digitalOceanAccount; + } + + getGcpAccount(): gcp.Account { + return this.gcpAccount; + } +} diff --git a/src/server_manager/web_app/ui_components/app-root.js b/src/server_manager/web_app/ui_components/app-root.js index bab21d846..7375ea661 100644 --- a/src/server_manager/web_app/ui_components/app-root.js +++ b/src/server_manager/web_app/ui_components/app-root.js @@ -29,6 +29,7 @@ import '@polymer/paper-menu-button/paper-menu-button.js'; import './cloud-install-styles.js'; import './outline-about-dialog.js'; import './outline-do-oauth-step.js'; +import './outline-gcp-oauth-step'; import './outline-feedback-dialog.js'; import './outline-survey-dialog.js'; import './outline-intro-step.js'; @@ -415,8 +416,9 @@ export class AppRoot extends mixinBehaviors
- + +
@@ -517,6 +519,7 @@ export class AppRoot extends mixinBehaviors type: Boolean, computed: '_computeIsDigitalOceanAccountConnected(digitalOceanAccountName)', }, + gcpAccountName: String, outlineVersion: String, userAcceptedTos: { type: Boolean, @@ -546,6 +549,7 @@ export class AppRoot extends mixinBehaviors /** @type {ServerListEntry[]} */ this.serverList = []; this.digitalOceanAccountName = ''; + this.gcpAccountName = ''; this.outlineVersion = ''; this.currentPage = 'intro'; this.shouldShowSideBar = false; @@ -626,6 +630,13 @@ export class AppRoot extends mixinBehaviors return oauthFlow; } + getAndShowGcpOauthFlow(onCancel) { + this.currentPage = 'gcpOauth'; + const oauthFlow = this.$.gcpOauth; + oauthFlow.onCancel = onCancel; + return oauthFlow; + } + getAndShowRegionPicker() { this.currentPage = 'regionPicker'; this.$.regionPicker.reset(); diff --git a/src/server_manager/web_app/ui_components/outline-gcp-oauth-step.ts b/src/server_manager/web_app/ui_components/outline-gcp-oauth-step.ts new file mode 100644 index 000000000..9395c6837 --- /dev/null +++ b/src/server_manager/web_app/ui_components/outline-gcp-oauth-step.ts @@ -0,0 +1,106 @@ +// Copyright 2021 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '@polymer/polymer/polymer-legacy.js'; +import '@polymer/iron-pages/iron-pages.js'; +import '../ui_components/outline-step-view.js'; + +import {css, customElement, html, LitElement, property} from 'lit-element'; +import {COMMON_STYLES} from '../ui_components/cloud-install-styles'; + +@customElement('outline-gcp-oauth-step') +export class GcpConnectAccountApp extends LitElement { + @property({type: Function}) onCancel: Function; + @property({type: Function}) localize: Function; + + static get styles() { + return [ + COMMON_STYLES, css` + :host { + } + .container { + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + align-items: center; + padding: 132px 0; + font-size: 14px; + } + #connectAccount img { + width: 48px; + height: 48px; + margin-bottom: 12px; + } + .card { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: space-between; + margin: 24px 0; + padding: 24px; + background: var(--background-contrast-color); + box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.14), 0 2px 2px 0 rgba(0, 0, 0, 0.12), 0 1px 3px 0 rgba(0, 0, 0, 0.2); + border-radius: 2px; + } + @media (min-width: 1025px) { + paper-card { + /* Set min with for the paper-card to grow responsively. */ + min-width: 600px; + } + } + .card p { + color: var(--light-gray); + width: 100%; + text-align: center; + } + .card paper-button { + color: var(--light-gray); + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 2px; + } + .card paper-button[disabled] { + color: var(--medium-gray); + background: transparent; + } + /* Mirror images */ + :host(:dir(rtl)) .mirror { + transform: scaleX(-1); + }` + ]; + // TODO: RTL + } + + render() { + return html` + + ${this.localize('gcp-oauth-connect-title')} + ${this.localize('oauth-connect-description')} + +
+ +

${this.localize('oauth-connect-tag')}

+
+ ${this.localize('cancel')} +
+
`; + } + + private onCancelTapped() { + if (this.onCancel) { + this.onCancel(); + } + } +} diff --git a/src/server_manager/web_app/ui_components/outline-intro-step.js b/src/server_manager/web_app/ui_components/outline-intro-step.js index ca03f1503..df32eaa94 100644 --- a/src/server_manager/web_app/ui_components/outline-intro-step.js +++ b/src/server_manager/web_app/ui_components/outline-intro-step.js @@ -270,11 +270,18 @@ Polymer({ is: 'outline-intro-step', properties: { - digitalOceanAccountName: String, + digitalOceanAccountName: { + type: String, + value: null, + }, isDigitalOceanAccountConnected: { type: Boolean, computed: '_computeIsDigitalOceanAccountConnected(digitalOceanAccountName)', }, + gcpAccountName: { + type: String, + value: null, + }, localize: { type: Function, readonly: true, @@ -302,6 +309,14 @@ Polymer({ }, setUpGcpTapped: function() { - this.fire('SetUpGcpRequested'); + if (outline.gcpAuthEnabled) { + if (this.gcpAccountName) { + this.fire('CreateGcpServerRequested'); + } else { + this.fire('ConnectGcpAccountRequested'); + } + } else { + this.fire('SetUpGcpRequested'); + } } }); diff --git a/yarn.lock b/yarn.lock index 17a74ee72..b0cff6a42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1284,6 +1284,13 @@ agent-base@5: resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-5.1.1.tgz#e8fb3f242959db44d63be665db7a8e739537a32c" integrity sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + agent-base@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" @@ -1791,6 +1798,11 @@ bignumber.js@^7.0.0: resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-7.2.1.tgz#80c048759d826800807c4bfd521e50edbba57a5f" integrity sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ== +bignumber.js@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.1.tgz#8d7ba124c882bfd8e43260c67475518d0689e4e5" + integrity sha512-IdZR9mh6ahOBv/hYGiXyVuyCetmGJhtYkqLBpTStdhEGjegpPlUawydyaF3pbIOFynJTpllEs+NP+CS9jKFLjA== + bin-build@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bin-build/-/bin-build-3.0.0.tgz#c5780a25a8a9f966d8244217e6c1f5082a143861" @@ -3510,7 +3522,7 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" -ecdsa-sig-formatter@1.0.11: +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== @@ -4554,6 +4566,17 @@ gaxios@^1.0.2, gaxios@^1.0.4, gaxios@^1.2.1, gaxios@^1.5.0: https-proxy-agent "^2.2.1" node-fetch "^2.3.0" +gaxios@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-4.1.0.tgz#e8ad466db5a4383c70b9d63bfd14dfaa87eb0099" + integrity sha512-vb0to8xzGnA2qcgywAjtshOKKVDf2eQhJoiL6fHhgW5tVN7wNk7egnYIO9zotfn3lQ3De1VPdf7V5/BWfCtCmg== + dependencies: + abort-controller "^3.0.0" + extend "^3.0.2" + https-proxy-agent "^5.0.0" + is-stream "^2.0.0" + node-fetch "^2.3.0" + gcp-metadata@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-1.0.0.tgz#5212440229fa099fc2f7c2a5cdcb95575e9b2ca6" @@ -4562,6 +4585,14 @@ gcp-metadata@^1.0.0: gaxios "^1.0.2" json-bigint "^0.3.0" +gcp-metadata@^4.2.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-4.2.1.tgz#31849fbcf9025ef34c2297c32a89a1e7e9f2cd62" + integrity sha512-tSk+REe5iq/N+K+SK1XjZJUrFPuDqGZVzCy2vocIHIGmPlTGsa8owXMJwGkrXr73NO0AzhPW4MF2DEHz7P2AVw== + dependencies: + gaxios "^4.0.0" + json-bigint "^1.0.0" + gcs-resumable-upload@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/gcs-resumable-upload/-/gcs-resumable-upload-1.1.0.tgz#2b06f5876dcf60f18a309343f79ed951aff01399" @@ -4844,6 +4875,21 @@ google-auth-library@^3.0.0, google-auth-library@^3.1.1: lru-cache "^5.0.0" semver "^5.5.0" +google-auth-library@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-7.0.2.tgz#cab6fc7f94ebecc97be6133d6519d9946ccf3e9d" + integrity sha512-vjyNZR3pDLC0u7GHLfj+Hw9tGprrJwoMwkYGqURCXYITjCrP9HprOyxVV+KekdLgATtWGuDkQG2MTh0qpUPUgg== + dependencies: + arrify "^2.0.0" + base64-js "^1.3.0" + ecdsa-sig-formatter "^1.0.11" + fast-text-encoding "^1.0.0" + gaxios "^4.0.0" + gcp-metadata "^4.2.0" + gtoken "^5.0.4" + jws "^4.0.0" + lru-cache "^6.0.0" + google-p12-pem@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-1.0.4.tgz#b77fb833a2eb9f7f3c689e2e54f095276f777605" @@ -4852,6 +4898,13 @@ google-p12-pem@^1.0.0: node-forge "^0.8.0" pify "^4.0.0" +google-p12-pem@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-3.0.3.tgz#673ac3a75d3903a87f05878f3c75e06fc151669e" + integrity sha512-wS0ek4ZtFx/ACKYF3JhyGe5kzH7pgiQ7J5otlumqR9psmWMYc+U9cErKlCYVYHoUaidXHdZ2xbo34kB+S+24hA== + dependencies: + node-forge "^0.10.0" + got@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/got/-/got-7.1.0.tgz#05450fd84094e6bbea56f451a43a9c289166385a" @@ -4928,6 +4981,15 @@ gtoken@^2.3.2: mime "^2.2.0" pify "^4.0.0" +gtoken@^5.0.4: + version "5.2.1" + resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-5.2.1.tgz#4dae1fea17270f457954b4a45234bba5fc796d16" + integrity sha512-OY0BfPKe3QnMsY9MzTHTSKn+Vl2l1CcLe6BwDEQj00mbbkl5nyQ/7EUREstg4fQNZ8iYE7br4JJ7TdKeDOPWmw== + dependencies: + gaxios "^4.0.0" + google-p12-pem "^3.0.3" + jws "^4.0.0" + gulp-cli@^2.2.0: version "2.2.1" resolved "https://registry.yarnpkg.com/gulp-cli/-/gulp-cli-2.2.1.tgz#376e427661b7996430a89d71c15df75defa3360a" @@ -5325,6 +5387,14 @@ https-proxy-agent@^4.0.0: agent-base "5" debug "4" +https-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" + integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== + dependencies: + agent-base "6" + debug "4" + husky@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/husky/-/husky-1.3.1.tgz#26823e399300388ca2afff11cfa8a86b0033fae0" @@ -6093,6 +6163,13 @@ json-bigint@^0.3.0: dependencies: bignumber.js "^7.0.0" +json-bigint@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1" + integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ== + dependencies: + bignumber.js "^9.0.0" + json-buffer@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" @@ -6194,6 +6271,15 @@ jwa@^1.4.1: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" +jwa@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc" + integrity sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + jws@^3.1.5: version "3.2.2" resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" @@ -6202,6 +6288,14 @@ jws@^3.1.5: jwa "^1.4.1" safe-buffer "^5.0.1" +jws@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4" + integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg== + dependencies: + jwa "^2.0.0" + safe-buffer "^5.0.1" + karma-chrome-launcher@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.1.0.tgz#805a586799a4d05f4e54f72a204979f3f3066738" @@ -6539,6 +6633,13 @@ lru-cache@^5.0.0, lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + lru_map@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd" @@ -10588,6 +10689,11 @@ yallist@^3.0.2: resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + yargs-parser@5.0.0-security.0: version "5.0.0-security.0" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-5.0.0-security.0.tgz#4ff7271d25f90ac15643b86076a2ab499ec9ee24"