Adds GCP OAuth (#838)

* gitignore

* Adds GCP token to CloudAccounts

* Checkpoint

* Checkpoint

* Refactors cloud accounts

* Addresses comments

* Auto-formatting

* Reverts gallery webpack GCP local server

* Reverts webpack config change

* Minor cleanup

* Removes unused class

* Reverts initial feature flag implementation

* Removes unneeded logging

* Adds credentials getters to CloudAccounts and updates tests

* Auto-format

* Adds FakeCloudAccounts

* Removes credentialsGetter from CloudAccounts

* Removes account factories from CloudAccounts

* Addresses review comments

* Addresses review commetns

* Auto-formatting

* Auto-format

* Replace OAuth config client id

* Remove account from CloudAccounts
This commit is contained in:
mpmcroy 2021-03-23 15:52:25 -04:00 • committed by GitHub
parent 15cc5889c4
commit b9222fe75c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1045 additions and 212 deletions

View file

@ -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

View file

@ -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 `<html><script>window.close()</script><body>${
messageHtml}. You can close this window.</body></html>`;
}
/**
* 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<boolean> {
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<string>((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'));
}
};
}

View file

@ -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.

View file

@ -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');

View file

@ -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'",

View file

@ -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."

View file

@ -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;
}

View file

@ -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<string>;
}

View file

@ -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",

View file

@ -30,4 +30,6 @@ interface OauthSession {
declare function runDigitalOceanOauth(): OauthSession;
declare function runGcpOauth(): OauthSession;
declare function bringToFront(): void;

View file

@ -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<server.AccessKeyId, number>());
}
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<void> {
return Promise.reject(new Error('FakeServer.setPortForNewAccessKeys not implemented'));
}
setAccessKeyDataLimit(accessKeyId: string, limit: server.DataLimit): Promise<void> {
return Promise.reject(new Error('FakeServer.setAccessKeyDataLimit not implemented'));
}
removeAccessKeyDataLimit(accessKeyId: string): Promise<void> {
return Promise.reject(new Error('FakeServer.removeAccessKeyDataLimit not implemented'));
}
setDefaultDataLimit(limit: server.DataLimit): Promise<void> {
return Promise.reject(new Error('FakeServer.setDefaultDataLimit not implemented'));
}
removeDefaultDataLimit(): Promise<void> {
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<void>((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<string> {
return 'fake-name';
}
async getStatus(): Promise<digitalocean.Status> {
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);
}
}

View file

@ -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<string, server.Server>();
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<string> {
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<string> {
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<void> {
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<void> {
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<void> {

View file

@ -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);
}

View file

@ -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);
}
}
}

View file

@ -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<string> {
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);

View file

@ -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 {
<h2>outline-do-oauth-step</h2>
<outline-do-oauth-step .localize=${this.localize} dir=${this.dir}></outline-do-oauth-step>
</div>
<div class="widget">
<h2>outline-gcp-oauth-step</h2>
<outline-gcp-oauth-step .localize=${this.localize} dir=${this.dir}></outline-gcp-oauth-step>
</div>
<div class="widget">
<h2>outline-feedback-dialog</h2>

View file

@ -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<string> {
return 'placeholder';
}
getRefreshToken(): string {
return this.refreshToken;
}
}

View file

@ -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();

View file

@ -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<string> {
return 'fake-digitalocean-account-name';
}
async getStatus(): Promise<digitalocean.Status> {
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<string> {
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<server.AccessKeyId, number>());
}
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<void> {
return Promise.reject(new Error('FakeServer.setPortForNewAccessKeys not implemented'));
}
setAccessKeyDataLimit(accessKeyId: string, limit: server.DataLimit): Promise<void> {
return Promise.reject(new Error('FakeServer.setAccessKeyDataLimit not implemented'));
}
removeAccessKeyDataLimit(accessKeyId: string): Promise<void> {
return Promise.reject(new Error('FakeServer.removeAccessKeyDataLimit not implemented'));
}
setDefaultDataLimit(limit: server.DataLimit): Promise<void> {
return Promise.reject(new Error('FakeServer.setDefaultDataLimit not implemented'));
}
removeDefaultDataLimit(): Promise<void> {
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<void>((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;
}
}

View file

@ -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
<app-header-layout>
<div class="app-container">
<iron-pages attr-for-selected="id" selected="{{ currentPage }}">
<outline-intro-step id="intro" digital-ocean-account-name="{{digitalOceanAccountName}}" localize="[[localize]]"></outline-intro-step>
<outline-intro-step id="intro" digital-ocean-account-name="{{digitalOceanAccountName}}" gcp-account-name="{{gcpAccountName}}" localize="[[localize]]"></outline-intro-step>
<outline-do-oauth-step id="digitalOceanOauth" localize="[[localize]]"></outline-do-oauth-step>
<outline-gcp-oauth-step id="gcpOauth" localize="[[localize]]"></outline-gcp-oauth-step>
<outline-manual-server-entry id="manualEntry" localize="[[localize]]"></outline-manual-server-entry>
<outline-region-picker-step id="regionPicker" localize="[[localize]]"></outline-region-picker-step>
<div id="serverView">
@ -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();

View file

@ -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`
<outline-step-view id="connectAccount">
<span slot="step-title">${this.localize('gcp-oauth-connect-title')}</span>
<span slot="step-description">${this.localize('oauth-connect-description')}</span>
<paper-card class="card">
<div class="container">
<img src="images/gcp-logo.svg">
<p>${this.localize('oauth-connect-tag')}</p>
</div>
<paper-button @tap="${this.onCancelTapped}">${this.localize('cancel')}</paper-button>
</paper-card>
</outline-step-view>`;
}
private onCancelTapped() {
if (this.onCancel) {
this.onCancel();
}
}
}

View file

@ -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');
}
}
});

108
yarn.lock
View file

@ -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"