diff --git a/src/server_manager/cloud/digitalocean_api.ts b/src/server_manager/cloud/digitalocean_api.ts index 52fd8e2b..54881204 100644 --- a/src/server_manager/cloud/digitalocean_api.ts +++ b/src/server_manager/cloud/digitalocean_api.ts @@ -49,7 +49,7 @@ export type Account = Readonly<{ email: string; uuid: string; email_verified: boolean; - status: string; + status: 'active' | 'warning' | 'locked'; status_message: string; }>; diff --git a/src/server_manager/messages/en.json b/src/server_manager/messages/en.json index 9420a5fe..f7e5261c 100644 --- a/src/server_manager/messages/en.json +++ b/src/server_manager/messages/en.json @@ -66,6 +66,8 @@ "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-do-limit": "Your DigitalOcean account has reached its Droplet limit. You can request an increase at https://cloud.digitalocean.com/account/team/droplet_limit_increase", + "error-do-warning": "DigitalOcean warning:", "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.", diff --git a/src/server_manager/messages/master_messages.json b/src/server_manager/messages/master_messages.json index eb82d413..02f6eb78 100644 --- a/src/server_manager/messages/master_messages.json +++ b/src/server_manager/messages/master_messages.json @@ -319,6 +319,14 @@ "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_do_limit": { + "message": "Your DigitalOcean account has reached its Droplet limit. You can request an increase at https://cloud.digitalocean.com/account/team/droplet_limit_increase", + "description": "This string appears in an error notification toast. It is shown when the user has created the maximum number of allowed servers." + }, + "error_do_warning": { + "message": "DigitalOcean warning:", + "description": "This string appears at the start of an error notification toast. It is followed by an English-language error description in quotes." + }, "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." diff --git a/src/server_manager/model/digitalocean.ts b/src/server_manager/model/digitalocean.ts index a70ac3e4..bc304ad8 100644 --- a/src/server_manager/model/digitalocean.ts +++ b/src/server_manager/model/digitalocean.ts @@ -38,15 +38,10 @@ export interface RegionOption extends location.CloudLocationOption { readonly cloudLocation: Region; } -export enum Status { - ACTIVE, - EMAIL_UNVERIFIED, - MISSING_BILLING_INFORMATION, -} - -export interface AccountInfo { - status: Status; - warning?: string; +export interface Status { + readonly needsBillingInfo: boolean; + readonly needsEmailVerification: boolean; + readonly warning?: string; } export interface Account { @@ -55,7 +50,7 @@ export interface Account { // Returns a user-friendly name (email address) associated with the account. getName(): Promise; // Returns the status of the account. - getStatus(): Promise; + getStatus(): Promise; // Lists all existing Shadowboxes. If `fetchFromHost` is true, performs a network request to // retrieve the servers; otherwise resolves with a cached server list. listServers(fetchFromHost?: boolean): Promise; @@ -64,6 +59,7 @@ export interface Account { // Creates a server and returning it when it becomes active (i.e. the server has // created, not necessarily once shadowbox installation has finished). createServer(region: Region, name: string): Promise; - + // Returns true if the account has reached its Droplet limit and + // will not be allowed to create new droplets. hasReachedLimit(): Promise; } diff --git a/src/server_manager/web_app/app.ts b/src/server_manager/web_app/app.ts index 20a828fa..d231d502 100644 --- a/src/server_manager/web_app/app.ts +++ b/src/server_manager/web_app/app.ts @@ -353,10 +353,13 @@ export class App { id: this.digitalOceanAccount.getId(), name: await this.digitalOceanAccount.getName(), }; - const info = await this.digitalOceanAccount.getStatus(); - if (info.status !== digitalocean.Status.ACTIVE) { + const status = await this.digitalOceanAccount.getStatus(); + if (status.needsBillingInfo || status.needsEmailVerification) { return []; } + if (status.warning) { + this.showDigitalOceanWarning(status.warning); + } const servers = await this.digitalOceanAccount.listServers(); for (const server of servers) { this.addServer(this.digitalOceanAccount.getId(), server); @@ -370,6 +373,10 @@ export class App { return []; } + private showDigitalOceanWarning(warning: string) { + this.appRoot.showError(`${this.appRoot.localize('error-do-warning')} ${warning}`); + } + private async loadGcpAccount(gcpAccount: gcp.Account): Promise { if (!gcpAccount) { return []; @@ -505,16 +512,20 @@ export class App { }; const oauthUi = this.appRoot.getDigitalOceanOauthFlow(signOutAction); for (;;) { - const info = await this.digitalOceanRetry(async () => { + const status = await this.digitalOceanRetry(async () => { if (cancelled) { throw CANCELLED_ERROR; } return await digitalOceanAccount.getStatus(); }); - if (info.warning) { - this.appRoot.showError(`DigitalOcean warning: ${info.warning}`); - } - if (info.status === digitalocean.Status.ACTIVE) { + if (status.needsBillingInfo) { + oauthUi.showBilling(); + } else if (status.needsEmailVerification) { + oauthUi.showEmailVerification(); + } else { + if (status.warning) { + this.showDigitalOceanWarning(status.warning); + } bringToFront(); if (activatingAccount) { // Show the 'account active' screen for a few seconds if the account was activated @@ -526,11 +537,6 @@ export class App { } this.appRoot.showDigitalOceanOauthFlow(); activatingAccount = true; - if (info.status === digitalocean.Status.MISSING_BILLING_INFORMATION) { - oauthUi.showBilling(); - } else { - oauthUi.showEmailVerification(); - } await sleep(1000); if (this.appRoot.currentPage !== 'digitalOceanOauth') { // The user navigated away. @@ -725,10 +731,8 @@ export class App { try { if (await digitalOceanAccount.hasReachedLimit()) { - this.appRoot.showError( - 'Your DigitalOcean account has reached its Droplet limit. You can request an increase at https://cloud.digitalocean.com/account/team/droplet_limit_increase' - ); - return; + this.appRoot.showError(this.appRoot.localize('error-do-limit')); + return; // Don't proceed to the region picker. } } catch (e) { console.error('Failed to check droplet limit status', e); diff --git a/src/server_manager/web_app/digitalocean_account.ts b/src/server_manager/web_app/digitalocean_account.ts index 7fb1a3ad..bd80e7c5 100644 --- a/src/server_manager/web_app/digitalocean_account.ts +++ b/src/server_manager/web_app/digitalocean_account.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {Account, DigitalOceanSession, DropletInfo, RestApiSession} from '../cloud/digitalocean_api'; +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'; @@ -46,31 +46,20 @@ export class DigitalOceanAccount implements digitalocean.Account { return (await this.digitalOcean.getAccount())?.email; } - async getStatus(): Promise { + async getStatus(): Promise { const account = await this.digitalOcean.getAccount(); - return { - status: await this.summarizeStatus(account), - warning: account.status !== 'active' ? account.status_message : '', - }; - } - - private async summarizeStatus(account: Account): Promise { - if (account.status === 'active') { - return digitalocean.Status.ACTIVE; + const needsEmailVerification = !account.email_verified; + let needsBillingInfo = false; + if (!needsEmailVerification && account.status === 'locked') { + // If the account is locked for no discernible reason, and there are no droplets, + // assume the billing info is missing. + const droplets = await this.digitalOcean.getDroplets(); + if (droplets.length == 0) { + needsBillingInfo = true; + } } - if (!account.email_verified) { - return digitalocean.Status.EMAIL_UNVERIFIED; - } - if (account.status === 'warning') { - return digitalocean.Status.ACTIVE; - } - const servers = await this.digitalOcean.getDroplets(); - if (servers.length > 0) { - // The account is locked, but it has droplets so it must have - // been activated with billing information. - return digitalocean.Status.ACTIVE; - } - return digitalocean.Status.MISSING_BILLING_INFORMATION; + const warning = account.status !== 'active' ? account.status_message : ''; + return {needsBillingInfo, needsEmailVerification, warning}; } // Return a list of regions indicating whether they are available and support diff --git a/src/server_manager/web_app/testing/models.ts b/src/server_manager/web_app/testing/models.ts index 07e93ad0..16bc9671 100644 --- a/src/server_manager/web_app/testing/models.ts +++ b/src/server_manager/web_app/testing/models.ts @@ -28,8 +28,8 @@ export class FakeDigitalOceanAccount implements digitalocean.Account { async getName(): Promise { return 'fake-digitalocean-account-name'; } - async getStatus(): Promise { - return {status: digitalocean.Status.ACTIVE}; + async getStatus(): Promise { + return {needsBillingInfo: false, needsEmailVerification: false}; } listServers() { return Promise.resolve(this.servers);