mirror of
https://github.com/OutlineFoundation/outline-server.git
synced 2026-10-24 13:31:02 +00:00
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:
parent
15cc5889c4
commit
b9222fe75c
23 changed files with 1045 additions and 212 deletions
|
|
@ -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
|
||||
|
|
|
|||
135
src/server_manager/electron_app/gcp_oauth.ts
Normal file
135
src/server_manager/electron_app/gcp_oauth.ts
Normal 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'));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
60
src/server_manager/model/accounts.ts
Normal file
60
src/server_manager/model/accounts.ts
Normal 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;
|
||||
}
|
||||
20
src/server_manager/model/gcp.ts
Normal file
20
src/server_manager/model/gcp.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
2
src/server_manager/types/preload.d.ts
vendored
2
src/server_manager/types/preload.d.ts
vendored
|
|
@ -30,4 +30,6 @@ interface OauthSession {
|
|||
|
||||
declare function runDigitalOceanOauth(): OauthSession;
|
||||
|
||||
declare function runGcpOauth(): OauthSession;
|
||||
|
||||
declare function bringToFront(): void;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
109
src/server_manager/web_app/cloud_accounts.spec.ts
Normal file
109
src/server_manager/web_app/cloud_accounts.spec.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
27
src/server_manager/web_app/gcp_account.ts
Normal file
27
src/server_manager/web_app/gcp_account.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
228
src/server_manager/web_app/testing/models.ts
Normal file
228
src/server_manager/web_app/testing/models.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
108
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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue