From d7836711b6fd5c5ead0ad87b08b3e979292bb83a Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Fri, 27 Oct 2023 14:59:21 -0400 Subject: [PATCH] Renames subscription folder to account --- src/commands/quickCommand.steps.ts | 2 +- src/constants.ts | 2 +- src/container.ts | 4 +- src/errors.ts | 4 +- src/features.ts | 2 +- src/git/gitProviderService.ts | 6 +- src/git/remotes/richRemoteProvider.ts | 2 +- src/plus/focus/focusService.ts | 2 +- src/plus/gk/account/authenticationConnection.ts | 216 +++++ src/plus/gk/account/authenticationProvider.ts | 295 ++++++ src/plus/gk/account/subscription.ts | 257 +++++ src/plus/gk/account/subscriptionService.ts | 1018 ++++++++++++++++++++ src/plus/gk/authenticationConnection.ts | 216 ----- src/plus/gk/authenticationProvider.ts | 295 ------ src/plus/gk/checkin.ts | 189 ++++ src/plus/gk/subscription/checkin.ts | 189 ---- src/plus/gk/subscription/subscription.ts | 257 ----- src/plus/gk/subscription/subscriptionService.ts | 1018 -------------------- src/plus/webviews/account/accountWebview.ts | 4 +- src/plus/webviews/account/protocol.ts | 2 +- src/plus/webviews/focus/focusWebview.ts | 2 +- src/plus/webviews/graph/graphWebview.ts | 2 +- src/plus/webviews/graph/protocol.ts | 2 +- src/plus/webviews/graph/statusbar.ts | 2 +- src/plus/webviews/timeline/timelineWebview.ts | 2 +- src/plus/workspaces/workspacesService.ts | 4 +- src/quickpicks/items/directive.ts | 2 +- src/uris/uriService.ts | 2 +- .../abstract/repositoriesSubscribeableNode.ts | 2 +- src/webviews/apps/plus/account/account.ts | 2 +- .../plus/account/components/account-content.ts | 2 +- src/webviews/apps/plus/graph/GraphWrapper.tsx | 2 +- .../shared/components/feature-gate-plus-state.ts | 2 +- .../apps/shared/components/feature-gate-badge.ts | 4 +- .../apps/shared/components/feature-gate.ts | 2 +- src/webviews/apps/tsconfig.json | 2 +- src/webviews/welcome/welcomeWebview.ts | 6 +- 37 files changed, 2011 insertions(+), 2011 deletions(-) create mode 100644 src/plus/gk/account/authenticationConnection.ts create mode 100644 src/plus/gk/account/authenticationProvider.ts create mode 100644 src/plus/gk/account/subscription.ts create mode 100644 src/plus/gk/account/subscriptionService.ts delete mode 100644 src/plus/gk/authenticationConnection.ts delete mode 100644 src/plus/gk/authenticationProvider.ts create mode 100644 src/plus/gk/checkin.ts delete mode 100644 src/plus/gk/subscription/checkin.ts delete mode 100644 src/plus/gk/subscription/subscription.ts delete mode 100644 src/plus/gk/subscription/subscriptionService.ts diff --git a/src/commands/quickCommand.steps.ts b/src/commands/quickCommand.steps.ts index f6a5de2..d3ffbb4 100644 --- a/src/commands/quickCommand.steps.ts +++ b/src/commands/quickCommand.steps.ts @@ -37,7 +37,7 @@ import type { GitTag, TagSortOptions } from '../git/models/tag'; import { sortTags } from '../git/models/tag'; import type { GitWorktree } from '../git/models/worktree'; import { remoteUrlRegex } from '../git/parsers/remoteParser'; -import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../plus/gk/subscription/subscription'; +import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../plus/gk/account/subscription'; import { CommitApplyFileChangesCommandQuickPickItem, CommitBrowseRepositoryFromHereCommandQuickPickItem, diff --git a/src/constants.ts b/src/constants.ts index 02be9af..8fcfa72 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,7 +1,7 @@ import type { ViewShowBranchComparison } from './config'; import type { Environment } from './container'; import type { StoredSearchQuery } from './git/search'; -import type { Subscription } from './plus/gk/subscription/subscription'; +import type { Subscription } from './plus/gk/account/subscription'; import type { TrackedUsage, TrackedUsageKeys } from './telemetry/usageTracker'; export const extensionPrefix = 'gitlens'; diff --git a/src/container.ts b/src/container.ts index a00e058..cdc15d7 100644 --- a/src/container.ts +++ b/src/container.ts @@ -22,9 +22,9 @@ import { RichRemoteProviderService } from './git/remotes/remoteProviderService'; import { LineHoverController } from './hovers/lineHoverController'; import type { RepositoryPathMappingProvider } from './pathMapping/repositoryPathMappingProvider'; import { FocusService } from './plus/focus/focusService'; -import { AccountAuthenticationProvider } from './plus/gk/authenticationProvider'; +import { AccountAuthenticationProvider } from './plus/gk/account/authenticationProvider'; +import { SubscriptionService } from './plus/gk/account/subscriptionService'; import { ServerConnection } from './plus/gk/serverConnection'; -import { SubscriptionService } from './plus/gk/subscription/subscriptionService'; import { IntegrationAuthenticationService } from './plus/integrationAuthentication'; import { registerAccountWebviewView } from './plus/webviews/account/registration'; import { registerFocusWebviewCommands, registerFocusWebviewPanel } from './plus/webviews/focus/registration'; diff --git a/src/errors.ts b/src/errors.ts index 001dcc3..173b4cf 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,8 +1,8 @@ import type { Uri } from 'vscode'; import { CancellationError as _CancellationError } from 'vscode'; import type { Response } from '@env/fetch'; -import type { RequiredSubscriptionPlans, Subscription } from './plus/gk/subscription/subscription'; -import { isSubscriptionPaidPlan } from './plus/gk/subscription/subscription'; +import type { RequiredSubscriptionPlans, Subscription } from './plus/gk/account/subscription'; +import { isSubscriptionPaidPlan } from './plus/gk/account/subscription'; export class AccessDeniedError extends Error { public readonly subscription: Subscription; diff --git a/src/features.ts b/src/features.ts index 82f9211..87e7807 100644 --- a/src/features.ts +++ b/src/features.ts @@ -1,5 +1,5 @@ import type { RepositoryVisibility } from './git/gitProvider'; -import type { RequiredSubscriptionPlans, Subscription } from './plus/gk/subscription/subscription'; +import type { RequiredSubscriptionPlans, Subscription } from './plus/gk/account/subscription'; export const enum Features { Stashes = 'stashes', diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index d8a152a..20bc734 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -17,9 +17,9 @@ import { GlyphChars, Schemes } from '../constants'; import type { Container } from '../container'; import { AccessDeniedError, CancellationError, ProviderNotFoundError } from '../errors'; import type { FeatureAccess, Features, PlusFeatures, RepoFeatureAccess } from '../features'; -import type { Subscription } from '../plus/gk/subscription/subscription'; -import { isSubscriptionPaidPlan, SubscriptionPlanId } from '../plus/gk/subscription/subscription'; -import type { SubscriptionChangeEvent } from '../plus/gk/subscription/subscriptionService'; +import type { Subscription } from '../plus/gk/account/subscription'; +import { isSubscriptionPaidPlan, SubscriptionPlanId } from '../plus/gk/account/subscription'; +import type { SubscriptionChangeEvent } from '../plus/gk/account/subscriptionService'; import type { RepoComparisonKey } from '../repositories'; import { asRepoComparisonKey, Repositories } from '../repositories'; import { groupByFilterMap, groupByMap, joinUnique } from '../system/array'; diff --git a/src/git/remotes/richRemoteProvider.ts b/src/git/remotes/richRemoteProvider.ts index de3b80f..a810b40 100644 --- a/src/git/remotes/richRemoteProvider.ts +++ b/src/git/remotes/richRemoteProvider.ts @@ -12,7 +12,7 @@ import { isWeb } from '@env/platform'; import type { Container } from '../../container'; import { AuthenticationError, ProviderRequestClientError } from '../../errors'; import { showIntegrationDisconnectedTooManyFailedRequestsWarningMessage } from '../../messages'; -import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../../plus/gk/subscription/subscription'; +import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../../plus/gk/account/subscription'; import type { IntegrationAuthenticationSessionDescriptor } from '../../plus/integrationAuthentication'; import { configuration } from '../../system/configuration'; import { gate } from '../../system/decorators/gate'; diff --git a/src/plus/focus/focusService.ts b/src/plus/focus/focusService.ts index ac5ee76..99139ae 100644 --- a/src/plus/focus/focusService.ts +++ b/src/plus/focus/focusService.ts @@ -3,10 +3,10 @@ import { window } from 'vscode'; import type { Container } from '../../container'; import type { GitRemote } from '../../git/models/remote'; import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; -import { isSubscriptionPaidPlan } from '../gk/subscription/subscription'; import { log } from '../../system/decorators/log'; import { Logger } from '../../system/logger'; import { getLogScope } from '../../system/logger.scope'; +import { isSubscriptionPaidPlan } from '../gk/account/subscription'; import type { ServerConnection } from '../gk/serverConnection'; export interface FocusItem { diff --git a/src/plus/gk/account/authenticationConnection.ts b/src/plus/gk/account/authenticationConnection.ts new file mode 100644 index 0000000..25b6cd2 --- /dev/null +++ b/src/plus/gk/account/authenticationConnection.ts @@ -0,0 +1,216 @@ +import type { CancellationToken, Disposable, StatusBarItem } from 'vscode'; +import { CancellationTokenSource, env, StatusBarAlignment, Uri, window } from 'vscode'; +import { uuid } from '@env/crypto'; +import type { Response } from '@env/fetch'; +import type { Container } from '../../../container'; +import { debug } from '../../../system/decorators/log'; +import type { DeferredEvent, DeferredEventExecutor } from '../../../system/event'; +import { promisifyDeferred } from '../../../system/event'; +import { Logger } from '../../../system/logger'; +import { getLogScope } from '../../../system/logger.scope'; +import type { ServerConnection } from '../serverConnection'; + +export const AuthenticationUriPathPrefix = 'did-authenticate'; + +interface AccountInfo { + id: string; + accountName: string; +} + +export class AuthenticationConnection implements Disposable { + private _cancellationSource: CancellationTokenSource | undefined; + private _deferredCodeExchanges = new Map>(); + private _pendingStates = new Map(); + private _statusBarItem: StatusBarItem | undefined; + + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) {} + + dispose() {} + + abort(): Promise { + if (this._cancellationSource == null) return Promise.resolve(); + + this._cancellationSource.cancel(); + // This should allow the current auth request to abort before continuing + return new Promise(resolve => setTimeout(resolve, 50)); + } + + @debug({ args: false }) + async getAccountInfo(token: string): Promise { + const scope = getLogScope(); + + let rsp: Response; + try { + rsp = await this.connection.fetchApi('user', undefined, token); + } catch (ex) { + Logger.error(ex, scope); + throw ex; + } + + if (!rsp.ok) { + Logger.error(undefined, `Getting account info failed: (${rsp.status}) ${rsp.statusText}`); + throw new Error(rsp.statusText); + } + + const json: { id: string; username: string } = await rsp.json(); + return { id: json.id, accountName: json.username }; + } + + @debug() + async login(scopes: string[], scopeKey: string): Promise { + this.updateStatusBarItem(true); + + // Include a state parameter here to prevent CSRF attacks + const gkstate = uuid(); + const existingStates = this._pendingStates.get(scopeKey) ?? []; + this._pendingStates.set(scopeKey, [...existingStates, gkstate]); + + const callbackUri = await env.asExternalUri( + Uri.parse( + `${env.uriScheme}://${this.container.context.extension.id}/${AuthenticationUriPathPrefix}?gkstate=${gkstate}`, + ), + ); + + const uri = this.connection.getAccountsUri( + 'register', + `${scopes.includes('gitlens') ? 'referrer=gitlens&' : ''}pass-token=true&return-url=${encodeURIComponent( + callbackUri.toString(), + )}`, + ); + void (await env.openExternal(uri)); + + // Ensure there is only a single listener for the URI callback, in case the user starts the login process multiple times before completing it + let deferredCodeExchange = this._deferredCodeExchanges.get(scopeKey); + if (deferredCodeExchange == null) { + deferredCodeExchange = promisifyDeferred( + this.container.uri.onDidReceiveAuthenticationUri, + this.getUriHandlerDeferredExecutor(scopeKey), + ); + this._deferredCodeExchanges.set(scopeKey, deferredCodeExchange); + } + + if (this._cancellationSource != null) { + this._cancellationSource.cancel(); + this._cancellationSource = undefined; + } + + this._cancellationSource = new CancellationTokenSource(); + + void this.openCompletionInputFallback(this._cancellationSource.token); + + return Promise.race([ + deferredCodeExchange.promise, + new Promise( + (_, reject) => + // eslint-disable-next-line prefer-promise-reject-errors + this._cancellationSource?.token.onCancellationRequested(() => reject('Cancelled')), + ), + new Promise((_, reject) => setTimeout(reject, 120000, 'Cancelled')), + ]).finally(() => { + this._cancellationSource?.cancel(); + this._cancellationSource = undefined; + + this._pendingStates.delete(scopeKey); + deferredCodeExchange?.cancel(); + this._deferredCodeExchanges.delete(scopeKey); + this.updateStatusBarItem(false); + }); + } + + private async openCompletionInputFallback(cancellationToken: CancellationToken) { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + try { + if (cancellationToken.isCancellationRequested) return; + + const uri = await new Promise(resolve => { + disposables.push( + cancellationToken.onCancellationRequested(() => input.hide()), + input.onDidHide(() => resolve(undefined)), + input.onDidChangeValue(e => { + if (!e) { + input.validationMessage = undefined; + return; + } + + try { + const uri = Uri.parse(e.trim()); + if (uri.scheme && uri.scheme !== 'file') { + input.validationMessage = undefined; + return; + } + } catch {} + + input.validationMessage = 'Please enter a valid authorization URL'; + }), + input.onDidAccept(() => resolve(Uri.parse(input.value.trim()))), + ); + + input.title = 'GitKraken Sign In'; + input.placeholder = 'Please enter the provided authorization URL'; + input.prompt = 'If the auto-redirect fails, paste the authorization URL'; + + input.show(); + }); + + if (uri != null) { + this.container.uri.handleUri(uri); + } + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + } + + private getUriHandlerDeferredExecutor(_scopeKey: string): DeferredEventExecutor { + return (uri: Uri, resolve, reject) => { + // TODO: We should really support a code to token exchange, but just return the token from the query string + // await this.exchangeCodeForToken(uri.query); + // As the backend still doesn't implement yet the code to token exchange, we just validate the state returned + const queryParams: URLSearchParams = new URLSearchParams(uri.query); + + const acceptedStates = this._pendingStates.get(_scopeKey); + const state = queryParams.get('gkstate'); + + if (acceptedStates == null || !state || !acceptedStates.includes(state)) { + // A common scenario of this happening is if you: + // 1. Trigger a sign in with one set of scopes + // 2. Before finishing 1, you trigger a sign in with a different set of scopes + // In this scenario we should just return and wait for the next UriHandler event + // to run as we are probably still waiting on the user to hit 'Continue' + Logger.log('State not found in accepted state. Skipping this execution...'); + return; + } + + const accessToken = queryParams.get('access-token'); + const code = queryParams.get('code'); + const token = accessToken ?? code; + + if (token == null) { + reject('Token not returned'); + } else { + resolve(token); + } + }; + } + + private updateStatusBarItem(signingIn?: boolean) { + if (signingIn && this._statusBarItem == null) { + this._statusBarItem = window.createStatusBarItem('gitlens.plus.signIn', StatusBarAlignment.Left); + this._statusBarItem.name = 'GitKraken Sign in'; + this._statusBarItem.text = 'Signing in to GitKraken...'; + this._statusBarItem.show(); + } + + if (!signingIn && this._statusBarItem != null) { + this._statusBarItem.dispose(); + this._statusBarItem = undefined; + } + } +} diff --git a/src/plus/gk/account/authenticationProvider.ts b/src/plus/gk/account/authenticationProvider.ts new file mode 100644 index 0000000..17b3eb6 --- /dev/null +++ b/src/plus/gk/account/authenticationProvider.ts @@ -0,0 +1,295 @@ +import type { + AuthenticationProvider, + AuthenticationProviderAuthenticationSessionsChangeEvent, + AuthenticationSession, +} from 'vscode'; +import { authentication, Disposable, EventEmitter, window } from 'vscode'; +import { uuid } from '@env/crypto'; +import type { Container, Environment } from '../../../container'; +import { debug } from '../../../system/decorators/log'; +import { Logger } from '../../../system/logger'; +import { getLogScope, setLogScopeExit } from '../../../system/logger.scope'; +import type { ServerConnection } from '../serverConnection'; +import { AuthenticationConnection } from './authenticationConnection'; + +interface StoredSession { + id: string; + accessToken: string; + account?: { + label?: string; + displayName?: string; + id: string; + }; + scopes: string[]; +} + +export const authenticationProviderId = 'gitlens+'; +export const authenticationProviderScopes = ['gitlens']; +const authenticationLabel = 'GitKraken: GitLens'; + +export class AccountAuthenticationProvider implements AuthenticationProvider, Disposable { + private _onDidChangeSessions = new EventEmitter(); + get onDidChangeSessions() { + return this._onDidChangeSessions.event; + } + + private readonly _disposable: Disposable; + private readonly _authConnection: AuthenticationConnection; + private _sessionsPromise: Promise; + + constructor( + private readonly container: Container, + connection: ServerConnection, + ) { + this._authConnection = new AuthenticationConnection(container, connection); + + // Contains the current state of the sessions we have available. + this._sessionsPromise = this.getSessionsFromStorage(); + + this._disposable = Disposable.from( + this._authConnection, + authentication.registerAuthenticationProvider(authenticationProviderId, authenticationLabel, this, { + supportsMultipleAccounts: false, + }), + this.container.storage.onDidChangeSecrets(() => this.checkForUpdates()), + ); + } + + dispose() { + this._disposable.dispose(); + } + + private get secretStorageKey(): `gitlens.plus.auth:${Environment}` { + return `gitlens.plus.auth:${this.container.env}`; + } + + abort(): Promise { + return this._authConnection.abort(); + } + + @debug() + public async createSession(scopes: string[]): Promise { + const scope = getLogScope(); + + // Ensure that the scopes are sorted consistently (since we use them for matching and order doesn't matter) + scopes = scopes.sort(); + const scopesKey = getScopesKey(scopes); + + try { + const token = await this._authConnection.login(scopes, scopesKey); + const session = await this.createSessionForToken(token, scopes); + + const sessions = await this._sessionsPromise; + const sessionIndex = sessions.findIndex(s => s.id === session.id || getScopesKey(s.scopes) === scopesKey); + if (sessionIndex > -1) { + sessions.splice(sessionIndex, 1, session); + } else { + sessions.push(session); + } + await this.storeSessions(sessions); + + this._onDidChangeSessions.fire({ added: [session], removed: [], changed: [] }); + + return session; + } catch (ex) { + // If login was cancelled, do not notify user. + if (ex === 'Cancelled') throw ex; + + Logger.error(ex, scope); + void window.showErrorMessage(`Unable to sign in to GitKraken: ${ex}`); + throw ex; + } + } + + @debug() + async getSessions(scopes?: string[]): Promise { + const scope = getLogScope(); + + scopes = scopes?.sort(); + const scopesKey = getScopesKey(scopes); + + const sessions = await this._sessionsPromise; + const filtered = scopes != null ? sessions.filter(s => getScopesKey(s.scopes) === scopesKey) : sessions; + + setLogScopeExit(scope, ` \u2022 Found ${filtered.length} sessions`); + + return filtered; + } + + @debug() + public async removeSession(id: string) { + const scope = getLogScope(); + + try { + const sessions = await this._sessionsPromise; + const sessionIndex = sessions.findIndex(session => session.id === id); + if (sessionIndex === -1) { + Logger.log(`Unable to remove session ${id}; Not found`); + return; + } + + const session = sessions[sessionIndex]; + sessions.splice(sessionIndex, 1); + + await this.storeSessions(sessions); + + this._onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); + } catch (ex) { + Logger.error(ex, scope); + void window.showErrorMessage(`Unable to sign out of GitKraken: ${ex}`); + throw ex; + } + } + + @debug() + public async removeSessionsByScopes(scopes?: string[]) { + const scope = getLogScope(); + + try { + scopes = scopes?.sort(); + const scopesKey = getScopesKey(scopes); + + const removed: AuthenticationSession[] = []; + + let index = 0; + + const sessions = await this._sessionsPromise; + + for (const session of sessions) { + if (getScopesKey(session.scopes) !== scopesKey) { + index++; + continue; + } + + sessions.splice(index, 1); + removed.push(session); + } + + if (removed.length === 0) return; + + await this.storeSessions(sessions); + + this._onDidChangeSessions.fire({ added: [], removed: removed, changed: [] }); + } catch (ex) { + Logger.error(ex, scope); + void window.showErrorMessage(`Unable to sign out of GitKraken: ${ex}`); + throw ex; + } + } + + private async checkForUpdates() { + const previousSessions = await this._sessionsPromise; + this._sessionsPromise = this.getSessionsFromStorage(); + const storedSessions = await this._sessionsPromise; + + const added: AuthenticationSession[] = []; + const removed: AuthenticationSession[] = []; + + for (const session of storedSessions) { + if (previousSessions.some(s => s.id === session.id)) continue; + + // Another window added a session, so let our window know about it + added.push(session); + } + + for (const session of previousSessions) { + if (storedSessions.some(s => s.id === session.id)) continue; + + // Another window has removed this session (or logged out), so let our window know about it + removed.push(session); + } + + if (added.length || removed.length) { + Logger.debug(`Firing sessions changed event; added=${added.length}, removed=${removed.length}`); + this._onDidChangeSessions.fire({ added: added, removed: removed, changed: [] }); + } + } + + private async createSessionForToken(token: string, scopes: string[]): Promise { + const userInfo = await this._authConnection.getAccountInfo(token); + return { + id: uuid(), + accessToken: token, + account: { label: userInfo.accountName, id: userInfo.id }, + scopes: scopes, + }; + } + + private async getSessionsFromStorage(): Promise { + let storedSessions: StoredSession[]; + + try { + const sessionsJSON = await this.container.storage.getSecret(this.secretStorageKey); + if (!sessionsJSON || sessionsJSON === '[]') return []; + + try { + storedSessions = JSON.parse(sessionsJSON); + } catch (ex) { + try { + await this.container.storage.deleteSecret(this.secretStorageKey); + } catch {} + + throw ex; + } + } catch (ex) { + Logger.error(ex, 'Unable to read sessions from storage'); + return []; + } + + const sessionPromises = storedSessions.map(async (session: StoredSession) => { + const scopesKey = getScopesKey(session.scopes); + + Logger.debug(`Read session from storage with scopes=${scopesKey}`); + + let userInfo: { id: string; accountName: string } | undefined; + if (session.account == null) { + try { + userInfo = await this._authConnection.getAccountInfo(session.accessToken); + Logger.debug(`Verified session with scopes=${scopesKey}`); + } catch (ex) { + // Remove sessions that return unauthorized response + if (ex.message === 'Unauthorized') return undefined; + } + } + + return { + id: session.id, + account: { + label: + session.account != null + ? session.account.label ?? session.account.displayName ?? '' + : userInfo?.accountName ?? '', + id: session.account?.id ?? userInfo?.id ?? '', + }, + scopes: session.scopes, + accessToken: session.accessToken, + }; + }); + + const verifiedSessions = (await Promise.allSettled(sessionPromises)) + .filter(p => p.status === 'fulfilled') + .map(p => (p as PromiseFulfilledResult).value) + .filter((p?: T): p is T => Boolean(p)); + + Logger.debug(`Found ${verifiedSessions.length} verified sessions`); + if (verifiedSessions.length !== storedSessions.length) { + await this.storeSessions(verifiedSessions); + } + return verifiedSessions; + } + + private async storeSessions(sessions: AuthenticationSession[]): Promise { + try { + this._sessionsPromise = Promise.resolve(sessions); + await this.container.storage.storeSecret(this.secretStorageKey, JSON.stringify(sessions)); + } catch (ex) { + Logger.error(ex, `Unable to store ${sessions.length} sessions`); + } + } +} + +function getScopesKey(scopes: readonly string[]): string; +function getScopesKey(scopes: readonly string[] | undefined): string | undefined; +function getScopesKey(scopes: readonly string[] | undefined): string | undefined { + return scopes?.join('|'); +} diff --git a/src/plus/gk/account/subscription.ts b/src/plus/gk/account/subscription.ts new file mode 100644 index 0000000..06aa94a --- /dev/null +++ b/src/plus/gk/account/subscription.ts @@ -0,0 +1,257 @@ +// NOTE@eamodio This file is referenced in the webviews to we can't use anything vscode or other imports that aren't available in the webviews +import { getDateDifference } from '../../../system/date'; + +export const enum SubscriptionPlanId { + Free = 'free', + FreePlus = 'free+', + Pro = 'pro', + Teams = 'teams', + Enterprise = 'enterprise', +} + +export type FreeSubscriptionPlans = Extract; +export type PaidSubscriptionPlans = Exclude; +export type RequiredSubscriptionPlans = Exclude; + +export interface Subscription { + readonly plan: { + readonly actual: SubscriptionPlan; + readonly effective: SubscriptionPlan; + }; + account: SubscriptionAccount | undefined; + previewTrial?: SubscriptionPreviewTrial; + + state: SubscriptionState; + + lastValidatedAt?: number; +} + +export interface SubscriptionPlan { + readonly id: SubscriptionPlanId; + readonly name: string; + readonly bundle: boolean; + readonly trialReactivationCount: number; + readonly cancelled: boolean; + readonly startedOn: string; + readonly expiresOn?: string | undefined; + readonly organizationId: string | undefined; +} + +export interface SubscriptionAccount { + readonly id: string; + readonly name: string; + readonly email: string | undefined; + readonly verified: boolean; + readonly createdOn: string; + readonly organizationIds: string[]; +} + +export interface SubscriptionPreviewTrial { + readonly startedOn: string; + readonly expiresOn: string; +} + +export const enum SubscriptionState { + /** Indicates a user who hasn't verified their email address yet */ + VerificationRequired = -1, + /** Indicates a Free user who hasn't yet started the preview trial */ + Free = 0, + /** Indicates a Free user who is in preview trial */ + FreeInPreviewTrial, + /** Indicates a Free user who's preview has expired trial */ + FreePreviewTrialExpired, + /** Indicates a Free+ user with a completed trial */ + FreePlusInTrial, + /** Indicates a Free+ user who's trial has expired */ + FreePlusTrialExpired, + /** Indicates a Paid user */ + Paid, +} + +export function computeSubscriptionState(subscription: Optional): SubscriptionState { + const { + account, + plan: { actual, effective }, + previewTrial: preview, + } = subscription; + + if (account?.verified === false) return SubscriptionState.VerificationRequired; + + if (actual.id === effective.id) { + switch (effective.id) { + case SubscriptionPlanId.Free: + return preview == null ? SubscriptionState.Free : SubscriptionState.FreePreviewTrialExpired; + + case SubscriptionPlanId.FreePlus: + return SubscriptionState.FreePlusTrialExpired; + + case SubscriptionPlanId.Pro: + case SubscriptionPlanId.Teams: + case SubscriptionPlanId.Enterprise: + return SubscriptionState.Paid; + } + } + + switch (effective.id) { + case SubscriptionPlanId.Free: + return preview == null ? SubscriptionState.Free : SubscriptionState.FreeInPreviewTrial; + + case SubscriptionPlanId.FreePlus: + return SubscriptionState.FreePlusTrialExpired; + + case SubscriptionPlanId.Pro: + return actual.id === SubscriptionPlanId.Free + ? SubscriptionState.FreeInPreviewTrial + : SubscriptionState.FreePlusInTrial; + + case SubscriptionPlanId.Teams: + case SubscriptionPlanId.Enterprise: + return SubscriptionState.Paid; + } +} + +export function getSubscriptionPlan( + id: SubscriptionPlanId, + bundle: boolean, + trialReactivationCount: number, + organizationId: string | undefined, + startedOn?: Date, + expiresOn?: Date, + cancelled: boolean = false, +): SubscriptionPlan { + return { + id: id, + name: getSubscriptionPlanName(id), + bundle: bundle, + cancelled: cancelled, + organizationId: organizationId, + trialReactivationCount: trialReactivationCount, + startedOn: (startedOn ?? new Date()).toISOString(), + expiresOn: expiresOn != null ? expiresOn.toISOString() : undefined, + }; +} + +export function getSubscriptionPlanName(id: SubscriptionPlanId) { + switch (id) { + case SubscriptionPlanId.FreePlus: + return 'GitKraken Free'; + case SubscriptionPlanId.Pro: + return 'GitKraken Pro'; + case SubscriptionPlanId.Teams: + return 'GitKraken Teams'; + case SubscriptionPlanId.Enterprise: + return 'GitKraken Enterprise'; + case SubscriptionPlanId.Free: + default: + return 'GitKraken'; + } +} + +export function getSubscriptionStatePlanName(state: SubscriptionState | undefined, id: SubscriptionPlanId | undefined) { + switch (state) { + case SubscriptionState.FreePlusTrialExpired: + return getSubscriptionPlanName(SubscriptionPlanId.FreePlus); + case SubscriptionState.FreeInPreviewTrial: + return `${getSubscriptionPlanName(SubscriptionPlanId.Pro)} (Trial)`; + case SubscriptionState.FreePlusInTrial: + return `${getSubscriptionPlanName(id ?? SubscriptionPlanId.Pro)} (Trial)`; + case SubscriptionState.VerificationRequired: + return `GitKraken (Unverified)`; + case SubscriptionState.Paid: + return getSubscriptionPlanName(id ?? SubscriptionPlanId.Pro); + case SubscriptionState.Free: + case SubscriptionState.FreePreviewTrialExpired: + case null: + default: + return 'GitKraken'; + } +} + +const plansPriority = new Map([ + [undefined, -1], + [SubscriptionPlanId.Free, 0], + [SubscriptionPlanId.FreePlus, 1], + [SubscriptionPlanId.Pro, 2], + [SubscriptionPlanId.Teams, 3], + [SubscriptionPlanId.Enterprise, 4], +]); + +export function getSubscriptionPlanPriority(id: SubscriptionPlanId | undefined): number { + return plansPriority.get(id) ?? -1; +} + +export function getSubscriptionTimeRemaining( + subscription: Optional, + unit?: 'days' | 'hours' | 'minutes' | 'seconds', +): number | undefined { + return getTimeRemaining(subscription.plan.effective.expiresOn, unit); +} + +export function getTimeRemaining( + expiresOn: string | undefined, + unit?: 'days' | 'hours' | 'minutes' | 'seconds', +): number | undefined { + return expiresOn != null ? getDateDifference(Date.now(), new Date(expiresOn), unit) : undefined; +} + +export function isSubscriptionPaid(subscription: Optional): boolean { + return isSubscriptionPaidPlan(subscription.plan.effective.id); +} + +export function isSubscriptionPaidPlan(id: SubscriptionPlanId): id is PaidSubscriptionPlans { + return id !== SubscriptionPlanId.Free && id !== SubscriptionPlanId.FreePlus; +} + +export function isSubscriptionExpired(subscription: Optional): boolean { + const remaining = getSubscriptionTimeRemaining(subscription); + return remaining != null && remaining <= 0; +} + +export function isSubscriptionTrial(subscription: Optional): boolean { + return subscription.plan.actual.id !== subscription.plan.effective.id; +} + +export function isSubscriptionInProTrial(subscription: Optional): boolean { + if ( + subscription.account == null || + !isSubscriptionTrial(subscription) || + isSubscriptionPreviewTrialExpired(subscription) === false + ) { + return false; + } + + const remaining = getSubscriptionTimeRemaining(subscription); + return remaining != null ? remaining <= 0 : true; +} + +export function isSubscriptionPreviewTrialExpired(subscription: Optional): boolean | undefined { + const remaining = getTimeRemaining(subscription.previewTrial?.expiresOn); + return remaining != null ? remaining <= 0 : undefined; +} + +export function isSubscriptionStatePaidOrTrial(state: SubscriptionState | undefined): boolean { + if (state == null) return false; + return ( + state === SubscriptionState.Paid || + state === SubscriptionState.FreeInPreviewTrial || + state === SubscriptionState.FreePlusInTrial + ); +} + +export function isSubscriptionStateTrial(state: SubscriptionState | undefined): boolean { + if (state == null) return false; + return state === SubscriptionState.FreeInPreviewTrial || state === SubscriptionState.FreePlusInTrial; +} + +export function hasAccountFromSubscriptionState(state: SubscriptionState | undefined): boolean { + if (state == null) return false; + return ( + state !== SubscriptionState.Free && + state !== SubscriptionState.FreePreviewTrialExpired && + state !== SubscriptionState.FreeInPreviewTrial + ); +} + +export function assertSubscriptionState( + subscription: Optional, +): asserts subscription is Subscription {} diff --git a/src/plus/gk/account/subscriptionService.ts b/src/plus/gk/account/subscriptionService.ts new file mode 100644 index 0000000..1da6710 --- /dev/null +++ b/src/plus/gk/account/subscriptionService.ts @@ -0,0 +1,1018 @@ +import type { + AuthenticationProviderAuthenticationSessionsChangeEvent, + AuthenticationSession, + CancellationToken, + Event, + MessageItem, + StatusBarItem, +} from 'vscode'; +import { + authentication, + CancellationTokenSource, + version as codeVersion, + Disposable, + env, + EventEmitter, + MarkdownString, + ProgressLocation, + StatusBarAlignment, + ThemeColor, + window, +} from 'vscode'; +import { getPlatform } from '@env/platform'; +import type { CoreColors } from '../../../constants'; +import { Commands } from '../../../constants'; +import type { Container } from '../../../container'; +import { AccountValidationError } from '../../../errors'; +import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; +import { executeCommand, registerCommand } from '../../../system/command'; +import { configuration } from '../../../system/configuration'; +import { setContext } from '../../../system/context'; +import { createFromDateDelta } from '../../../system/date'; +import { gate } from '../../../system/decorators/gate'; +import { debug, log } from '../../../system/decorators/log'; +import type { Deferrable } from '../../../system/function'; +import { debounce, once } from '../../../system/function'; +import { Logger } from '../../../system/logger'; +import { getLogScope } from '../../../system/logger.scope'; +import { flatten } from '../../../system/object'; +import { pluralize } from '../../../system/string'; +import { openWalkthrough } from '../../../system/utils'; +import { satisfies } from '../../../system/version'; +import type { GKCheckInResponse } from '../checkin'; +import { getSubscriptionFromCheckIn } from '../checkin'; +import type { ServerConnection } from '../serverConnection'; +import { ensurePlusFeaturesEnabled } from '../utils'; +import { authenticationProviderId, authenticationProviderScopes } from './authenticationProvider'; +import type { Subscription } from './subscription'; +import { + assertSubscriptionState, + computeSubscriptionState, + getSubscriptionPlan, + getSubscriptionPlanName, + getSubscriptionTimeRemaining, + getTimeRemaining, + isSubscriptionExpired, + isSubscriptionInProTrial, + isSubscriptionPaid, + isSubscriptionTrial, + SubscriptionPlanId, + SubscriptionState, +} from './subscription'; + +export interface SubscriptionChangeEvent { + readonly current: Subscription; + readonly previous: Subscription; + readonly etag: number; +} + +export class SubscriptionService implements Disposable { + private _onDidChange = new EventEmitter(); + get onDidChange(): Event { + return this._onDidChange.event; + } + + private _disposable: Disposable; + private _subscription!: Subscription; + private _statusBarSubscription: StatusBarItem | undefined; + private _validationTimer: ReturnType | undefined; + + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + previousVersion: string | undefined, + ) { + this._disposable = Disposable.from( + once(container.onReady)(this.onReady, this), + this.container.accountAuthentication.onDidChangeSessions( + e => setTimeout(() => this.onAuthenticationChanged(e), 0), + this, + ), + configuration.onDidChange(e => { + if (configuration.changed(e, 'plusFeatures')) { + this.updateContext(); + } + }), + ); + + const subscription = this.getStoredSubscription(); + // Resets the preview trial state on the upgrade to 14.0 + if (subscription != null && satisfies(previousVersion, '< 14.0')) { + subscription.previewTrial = undefined; + } + + this.changeSubscription(subscription, { silent: true }); + setTimeout(() => void this.ensureSession(false), 10000); + } + + dispose(): void { + this._statusBarSubscription?.dispose(); + + this._disposable.dispose(); + } + + private async onAuthenticationChanged(e: AuthenticationProviderAuthenticationSessionsChangeEvent) { + let session = this._session; + if (session == null && this._sessionPromise != null) { + session = await this._sessionPromise; + } + + if (session != null && e.removed?.some(s => s.id === session!.id)) { + this._session = undefined; + this._sessionPromise = undefined; + void this.logout(); + return; + } + + const updated = e.added?.[0] ?? e.changed?.[0]; + if (updated == null) return; + + if (updated.id === session?.id && updated.accessToken === session?.accessToken) { + return; + } + + this._session = session; + void this.validate({ force: true }); + } + + private _etag: number = 0; + get etag(): number { + return this._etag; + } + + private onReady() { + this._disposable = Disposable.from( + this._disposable, + this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), + ...this.registerCommands(), + ); + this.updateContext(); + } + + private onRepositoriesChanged(_e: RepositoriesChangeEvent): void { + this.updateContext(); + } + + private registerCommands(): Disposable[] { + void this.container.viewCommands; + + return [ + registerCommand(Commands.PlusLoginOrSignUp, () => this.loginOrSignUp()), + registerCommand(Commands.PlusLogout, () => this.logout()), + + registerCommand(Commands.PlusStartPreviewTrial, () => this.startPreviewTrial()), + registerCommand(Commands.PlusManage, () => this.manage()), + registerCommand(Commands.PlusPurchase, () => this.purchase()), + + registerCommand(Commands.PlusResendVerification, () => this.resendVerification()), + registerCommand(Commands.PlusValidate, () => this.validate({ force: true })), + + registerCommand(Commands.PlusShowPlans, () => this.showPlans()), + + registerCommand(Commands.PlusHide, () => configuration.updateEffective('plusFeatures.enabled', false)), + registerCommand(Commands.PlusRestore, () => configuration.updateEffective('plusFeatures.enabled', true)), + + registerCommand('gitlens.plus.reset', () => this.logout(true)), + ]; + } + + async getAuthenticationSession(createIfNeeded: boolean = false): Promise { + return this.ensureSession(createIfNeeded); + } + + async getSubscription(cached = false): Promise { + const promise = this.ensureSession(false); + if (!cached) { + void (await promise); + } + return this._subscription; + } + + @debug() + async learnAboutPreviewOrTrial() { + const subscription = await this.getSubscription(); + if (subscription.state === SubscriptionState.FreeInPreviewTrial) { + void openWalkthrough( + this.container.context.extension.id, + 'gitlens.welcome', + 'gitlens.welcome.preview', + false, + ); + } else if (subscription.state === SubscriptionState.FreePlusInTrial) { + void openWalkthrough( + this.container.context.extension.id, + 'gitlens.welcome', + 'gitlens.welcome.trial', + false, + ); + } + } + + @log() + async loginOrSignUp(): Promise { + if (!(await ensurePlusFeaturesEnabled())) return false; + + // Abort any waiting authentication to ensure we can start a new flow + await this.container.accountAuthentication.abort(); + void this.showAccountView(); + + const session = await this.ensureSession(true); + const loggedIn = Boolean(session); + if (loggedIn) { + const { + account, + plan: { actual, effective }, + } = this._subscription; + + if (account?.verified === false) { + const confirm: MessageItem = { title: 'Resend Verification', isCloseAffordance: true }; + const cancel: MessageItem = { title: 'Cancel' }; + const result = await window.showInformationMessage( + `You must verify your email before you can access ${effective.name}.`, + confirm, + cancel, + ); + + if (result === confirm) { + void this.resendVerification(); + } + } else if (isSubscriptionTrial(this._subscription)) { + const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); + + const confirm: MessageItem = { title: 'OK', isCloseAffordance: true }; + const learn: MessageItem = { title: 'Learn More' }; + const result = await window.showInformationMessage( + `Welcome to ${ + effective.name + } (Trial). You can now try Pro features on privately hosted repos for ${pluralize( + 'more day', + remaining ?? 0, + )}.`, + { modal: true }, + confirm, + learn, + ); + + if (result === learn) { + void this.learnAboutPreviewOrTrial(); + } + } else if (isSubscriptionPaid(this._subscription)) { + void window.showInformationMessage( + `Welcome to ${actual.name}. You can now use Pro features on privately hosted repos.`, + 'OK', + ); + } else { + void window.showInformationMessage( + `Welcome to ${actual.name}. You can use Pro features on local & publicly hosted repos.`, + 'OK', + ); + } + } + return loggedIn; + } + + @log() + async logout(reset: boolean = false): Promise { + return this.logoutCore(reset); + } + + private async logoutCore(reset: boolean = false): Promise { + this._lastValidatedDate = undefined; + if (this._validationTimer != null) { + clearInterval(this._validationTimer); + this._validationTimer = undefined; + } + + await this.container.accountAuthentication.abort(); + + this._sessionPromise = undefined; + if (this._session != null) { + void this.container.accountAuthentication.removeSession(this._session.id); + this._session = undefined; + } else { + // Even if we don't have a session, make sure to remove any other matching sessions + void this.container.accountAuthentication.removeSessionsByScopes(authenticationProviderScopes); + } + + if (reset && this.container.debugging) { + this.changeSubscription(undefined); + + return; + } + + this.changeSubscription({ + ...this._subscription, + plan: { + actual: getSubscriptionPlan( + SubscriptionPlanId.Free, + false, + 0, + undefined, + this._subscription.plan?.actual?.startedOn != null + ? new Date(this._subscription.plan.actual.startedOn) + : undefined, + ), + effective: getSubscriptionPlan( + SubscriptionPlanId.Free, + false, + 0, + undefined, + this._subscription.plan?.effective?.startedOn != null + ? new Date(this._subscription.plan.actual.startedOn) + : undefined, + ), + }, + account: undefined, + }); + } + + @log() + manage(): void { + void env.openExternal(this.connection.getAccountsUri()); + } + + @log() + async purchase(): Promise { + if (!(await ensurePlusFeaturesEnabled())) return; + + if (this._subscription.account == null) { + this.showPlans(); + } else { + void env.openExternal(this.connection.getAccountsUri('subscription', 'product=gitlens&license=PRO')); + } + await this.showAccountView(); + } + + @gate() + @log() + async resendVerification(): Promise { + if (this._subscription.account?.verified) return true; + + const scope = getLogScope(); + + void this.showAccountView(true); + + const session = await this.ensureSession(false); + if (session == null) return false; + + try { + const rsp = await this.connection.fetchApi( + 'resend-email', + { + method: 'POST', + body: JSON.stringify({ id: session.account.id }), + }, + session.accessToken, + ); + + if (!rsp.ok) { + debugger; + Logger.error( + '', + scope, + `Unable to resend verification email; status=(${rsp.status}): ${rsp.statusText}`, + ); + + void window.showErrorMessage(`Unable to resend verification email; Status: ${rsp.statusText}`, 'OK'); + + return false; + } + + const confirm = { title: 'Recheck' }; + const cancel = { title: 'Cancel' }; + const result = await window.showInformationMessage( + "Once you have verified your email address, click 'Recheck'.", + confirm, + cancel, + ); + + if (result === confirm) { + await this.validate({ force: true }); + return true; + } + } catch (ex) { + Logger.error(ex, scope); + debugger; + + void window.showErrorMessage('Unable to resend verification email', 'OK'); + } + + return false; + } + + @log() + async showAccountView(silent: boolean = false): Promise { + if (silent && !configuration.get('plusFeatures.enabled', undefined, true)) return; + + if (!this.container.accountView.visible) { + await executeCommand(Commands.ShowAccountView); + } + } + + private showPlans(): void { + void env.openExternal(this.connection.getSiteUri('gitlens/pricing')); + } + + @gate() + @log() + async startPreviewTrial(silent?: boolean): Promise { + if (!(await ensurePlusFeaturesEnabled())) return; + + let { plan, previewTrial } = this._subscription; + if (previewTrial != null) { + void this.showAccountView(); + + if (!silent && plan.effective.id === SubscriptionPlanId.Free) { + const confirm: MessageItem = { title: 'Start Free GitKraken Trial', isCloseAffordance: true }; + const cancel: MessageItem = { title: 'Cancel' }; + const result = await window.showInformationMessage( + 'Your 3-day Pro preview has ended, start a free GitKraken trial to get an additional 7 days.\n\n✨ A trial or paid plan is required to use Pro features on privately hosted repos.', + { modal: true }, + confirm, + cancel, + ); + + if (result === confirm) { + void this.loginOrSignUp(); + } + } + + return; + } + + // Don't overwrite a trial that is already in progress + if (isSubscriptionInProTrial(this._subscription)) return; + + const startedOn = new Date(); + + let days: number; + let expiresOn = new Date(startedOn); + if (!this.container.debugging) { + // Normalize the date to just before midnight on the same day + expiresOn.setHours(23, 59, 59, 999); + expiresOn = createFromDateDelta(expiresOn, { days: 3 }); + days = 3; + } else { + expiresOn = createFromDateDelta(expiresOn, { minutes: 1 }); + days = 0; + } + + previewTrial = { + startedOn: startedOn.toISOString(), + expiresOn: expiresOn.toISOString(), + }; + + this.changeSubscription({ + ...this._subscription, + plan: { + ...this._subscription.plan, + effective: getSubscriptionPlan(SubscriptionPlanId.Pro, false, 0, undefined, startedOn, expiresOn), + }, + previewTrial: previewTrial, + }); + + if (!silent) { + setTimeout(async () => { + const confirm: MessageItem = { title: 'OK', isCloseAffordance: true }; + const learn: MessageItem = { title: 'Learn More' }; + const result = await window.showInformationMessage( + `You can now preview Pro features for ${pluralize( + 'day', + days, + )}. After which, you can start a free GitKraken trial for an additional 7 days.`, + confirm, + learn, + ); + + if (result === learn) { + void this.learnAboutPreviewOrTrial(); + } + }, 1); + } + } + + @gate() + @log() + async validate(options?: { force?: boolean }): Promise { + const scope = getLogScope(); + + const session = await this.ensureSession(false); + if (session == null) { + this.changeSubscription(this._subscription); + return; + } + + try { + await this.checkInAndValidate(session, options); + } catch (ex) { + Logger.error(ex, scope); + debugger; + } + } + + private _lastValidatedDate: Date | undefined; + @gate(s => s.account.id) + private async checkInAndValidate( + session: AuthenticationSession, + options?: { force?: boolean; showSlowProgress?: boolean }, + ): Promise { + // Only check in if we haven't in the last 12 hours + if ( + !options?.force && + this._lastValidatedDate != null && + Date.now() - this._lastValidatedDate.getTime() < 12 * 60 * 60 * 1000 && + !isSubscriptionExpired(this._subscription) + ) { + return; + } + + if (!options?.showSlowProgress) return this.checkInAndValidateCore(session); + + const validating = this.checkInAndValidateCore(session); + const result = await Promise.race([ + validating, + new Promise(resolve => setTimeout(resolve, 3000, true)), + ]); + + if (result) { + await window.withProgress( + { + location: ProgressLocation.Notification, + title: 'Validating your GitKraken account...', + }, + () => validating, + ); + } + } + + @debug({ args: { 0: s => s?.account.label } }) + private async checkInAndValidateCore(session: AuthenticationSession): Promise { + const scope = getLogScope(); + this._lastValidatedDate = undefined; + + try { + const checkInData = { + id: session.account.id, + platform: getPlatform(), + gitlensVersion: this.container.version, + machineId: env.machineId, + sessionId: env.sessionId, + vscodeEdition: env.appName, + vscodeHost: env.appHost, + vscodeVersion: codeVersion, + previewStartedOn: this._subscription.previewTrial?.startedOn, + previewExpiresOn: this._subscription.previewTrial?.expiresOn, + }; + + const rsp = await this.connection.fetchApi( + 'gitlens/checkin', + { + method: 'POST', + body: JSON.stringify(checkInData), + }, + session.accessToken, + ); + + if (!rsp.ok) { + throw new AccountValidationError('Unable to validate account', undefined, rsp.status, rsp.statusText); + } + + const data: GKCheckInResponse = await rsp.json(); + this.validateAndUpdateSubscription(data); + } catch (ex) { + Logger.error(ex, scope); + debugger; + if (ex instanceof AccountValidationError) throw ex; + + throw new AccountValidationError('Unable to validate account', ex); + } finally { + this.startDailyValidationTimer(); + } + } + + private startDailyValidationTimer(): void { + if (this._validationTimer != null) { + clearInterval(this._validationTimer); + } + + // Check 4 times a day to ensure we validate at least once a day + this._validationTimer = setInterval( + () => { + if (this._lastValidatedDate == null || this._lastValidatedDate.getDate() !== new Date().getDate()) { + void this.ensureSession(false, true); + } + }, + 6 * 60 * 60 * 1000, + ); + } + + @debug() + private validateAndUpdateSubscription(data: GKCheckInResponse) { + const subscription = getSubscriptionFromCheckIn(data); + + this._lastValidatedDate = new Date(); + this.changeSubscription( + { + ...this._subscription, + ...subscription, + }, + { store: true }, + ); + } + + private _sessionPromise: Promise | undefined; + private _session: AuthenticationSession | null | undefined; + + @gate() + @debug() + private async ensureSession(createIfNeeded: boolean, force?: boolean): Promise { + if (this._sessionPromise != null && this._session === undefined) { + void (await this._sessionPromise); + } + + if (!force && this._session != null) return this._session; + if (this._session === null && !createIfNeeded) return undefined; + + if (this._sessionPromise === undefined) { + this._sessionPromise = this.getOrCreateSession(createIfNeeded).then( + s => { + this._session = s; + this._sessionPromise = undefined; + return this._session; + }, + () => { + this._session = null; + this._sessionPromise = undefined; + return this._session; + }, + ); + } + + const session = await this._sessionPromise; + return session ?? undefined; + } + + @debug() + private async getOrCreateSession(createIfNeeded: boolean): Promise { + const scope = getLogScope(); + + let session: AuthenticationSession | null | undefined; + + try { + session = await authentication.getSession(authenticationProviderId, authenticationProviderScopes, { + createIfNone: createIfNeeded, + silent: !createIfNeeded, + }); + } catch (ex) { + session = null; + + if (ex instanceof Error && ex.message.includes('User did not consent')) { + Logger.debug(scope, 'User declined authentication'); + await this.logoutCore(); + return null; + } + + Logger.error(ex, scope); + } + + if (session == null) { + Logger.debug(scope, 'No valid session was found'); + await this.logoutCore(); + return session ?? null; + } + + try { + await this.checkInAndValidate(session, { showSlowProgress: createIfNeeded, force: createIfNeeded }); + } catch (ex) { + Logger.error(ex, scope); + debugger; + + this.container.telemetry.sendEvent('account/validation/failed', { + 'account.id': session.account.id, + exception: String(ex), + code: ex.original?.code, + statusCode: ex.statusCode, + }); + + Logger.debug(scope, `Account validation failed (${ex.statusCode ?? ex.original?.code})`); + + if (ex instanceof AccountValidationError) { + const name = session.account.label; + + // if ( + // (ex.statusCode != null && ex.statusCode < 500) || + // (ex.statusCode == null && (ex.original as any)?.code !== 'ENOTFOUND') + // ) { + if ( + (ex.original as any)?.code !== 'ENOTFOUND' && + ex.statusCode != null && + ex.statusCode < 500 && + ex.statusCode >= 400 + ) { + session = null; + await this.logoutCore(); + + if (createIfNeeded) { + const unauthorized = ex.statusCode === 401; + queueMicrotask(async () => { + const confirm: MessageItem = { title: 'Retry Sign In' }; + const result = await window.showErrorMessage( + `Unable to sign in to your (${name}) GitKraken account. Please try again. If this issue persists, please contact support.${ + unauthorized ? '' : ` Error=${ex.message}` + }`, + confirm, + ); + + if (result === confirm) { + void this.loginOrSignUp(); + } + }); + } + } else { + session = session ?? null; + + // if ((ex.original as any)?.code !== 'ENOTFOUND') { + // void window.showErrorMessage( + // `Unable to sign in to your (${name}) GitKraken account right now. Please try again in a few minutes. If this issue persists, please contact support. Error=${ex.message}`, + // 'OK', + // ); + // } + } + } + } + + return session; + } + + @debug() + private changeSubscription( + subscription: Optional | undefined, + options?: { silent?: boolean; store?: boolean }, + ): void { + if (subscription == null) { + subscription = { + plan: { + actual: getSubscriptionPlan(SubscriptionPlanId.Free, false, 0, undefined), + effective: getSubscriptionPlan(SubscriptionPlanId.Free, false, 0, undefined), + }, + account: undefined, + state: SubscriptionState.Free, + }; + } + + // If the effective plan has expired, then replace it with the actual plan + if (isSubscriptionExpired(subscription)) { + subscription = { + ...subscription, + plan: { + ...subscription.plan, + effective: subscription.plan.actual, + }, + }; + } + + // If we don't have a paid plan (or a non-preview trial), check if the preview trial has expired, if not apply it + if ( + !isSubscriptionPaid(subscription) && + subscription.previewTrial != null && + (getTimeRemaining(subscription.previewTrial.expiresOn) ?? 0) > 0 + ) { + subscription = { + ...subscription, + plan: { + ...subscription.plan, + effective: getSubscriptionPlan( + SubscriptionPlanId.Pro, + false, + 0, + undefined, + new Date(subscription.previewTrial.startedOn), + new Date(subscription.previewTrial.expiresOn), + ), + }, + }; + } + + subscription.state = computeSubscriptionState(subscription); + assertSubscriptionState(subscription); + + const previous = this._subscription as typeof this._subscription | undefined; // Can be undefined here, since we call this in the constructor + // Check the previous and new subscriptions are exactly the same + const matches = previous != null && JSON.stringify(previous) === JSON.stringify(subscription); + + // If the previous and new subscriptions are exactly the same, kick out + if (matches) { + if (options?.store) { + void this.storeSubscription(subscription); + } + return; + } + + queueMicrotask(() => { + let data = flattenSubscription(subscription); + this.container.telemetry.setGlobalAttributes(data); + + data = { + ...data, + ...(!matches ? flattenSubscription(previous, 'previous') : {}), + }; + + this.container.telemetry.sendEvent(previous == null ? 'subscription' : 'subscription/changed', data); + }); + + void this.storeSubscription(subscription); + + this._subscription = subscription; + this._etag = Date.now(); + + if (!options?.silent) { + this.updateContext(); + + if (previous != null) { + this._onDidChange.fire({ current: subscription, previous: previous, etag: this._etag }); + } + } + } + + private getStoredSubscription(): Subscription | undefined { + const storedSubscription = this.container.storage.get('premium:subscription'); + + let lastValidatedAt: number | undefined; + let subscription: Subscription | undefined; + if (storedSubscription?.data != null) { + ({ lastValidatedAt, ...subscription } = storedSubscription.data); + this._lastValidatedDate = lastValidatedAt != null ? new Date(lastValidatedAt) : undefined; + } else { + subscription = undefined; + } + + if (subscription != null) { + // Migrate the plan names to the latest names + (subscription.plan.actual as Mutable).name = getSubscriptionPlanName( + subscription.plan.actual.id, + ); + (subscription.plan.effective as Mutable).name = getSubscriptionPlanName( + subscription.plan.effective.id, + ); + } + + return subscription; + } + + private async storeSubscription(subscription: Subscription): Promise { + return this.container.storage.store('premium:subscription', { + v: 1, + data: { ...subscription, lastValidatedAt: this._lastValidatedDate?.getTime() }, + }); + } + + private _cancellationSource: CancellationTokenSource | undefined; + private _updateAccessContextDebounced: Deferrable | undefined; + + private updateContext(): void { + this._updateAccessContextDebounced?.cancel(); + if (this._updateAccessContextDebounced == null) { + this._updateAccessContextDebounced = debounce(this.updateAccessContext.bind(this), 500); + } + + if (this._cancellationSource != null) { + this._cancellationSource.cancel(); + } + this._cancellationSource = new CancellationTokenSource(); + + void this._updateAccessContextDebounced(this._cancellationSource.token); + this.updateStatusBar(); + + const { + plan: { actual }, + state, + } = this._subscription; + + void setContext('gitlens:plus', actual.id != SubscriptionPlanId.Free ? actual.id : undefined); + void setContext('gitlens:plus:state', state); + } + + private async updateAccessContext(cancellation: CancellationToken): Promise { + let allowed: boolean | 'mixed' = false; + // For performance reasons, only check if we have any repositories + if (this.container.git.repositoryCount !== 0) { + ({ allowed } = await this.container.git.access()); + if (cancellation.isCancellationRequested) return; + } + + const plusFeatures = configuration.get('plusFeatures.enabled') ?? true; + + let disallowedRepos: string[] | undefined; + + if (!plusFeatures && allowed === 'mixed') { + disallowedRepos = []; + for (const repo of this.container.git.repositories) { + if (repo.closed) continue; + + const access = await this.container.git.access(undefined, repo.uri); + if (cancellation.isCancellationRequested) return; + + if (!access.allowed) { + disallowedRepos.push(repo.uri.toString()); + } + } + } + + void setContext('gitlens:plus:enabled', Boolean(allowed) || plusFeatures); + void setContext('gitlens:plus:required', allowed === false); + void setContext('gitlens:plus:disallowedRepos', disallowedRepos); + } + + private updateStatusBar(): void { + const { + account, + plan: { effective }, + state, + } = this._subscription; + + if (effective.id === SubscriptionPlanId.Free) { + this._statusBarSubscription?.dispose(); + this._statusBarSubscription = undefined; + return; + } + + const trial = isSubscriptionTrial(this._subscription); + if (!trial && account?.verified !== false) { + this._statusBarSubscription?.dispose(); + this._statusBarSubscription = undefined; + return; + } + + if (this._statusBarSubscription == null) { + this._statusBarSubscription = window.createStatusBarItem( + 'gitlens.plus.subscription', + StatusBarAlignment.Left, + 1, + ); + } + + this._statusBarSubscription.name = 'GitKraken Subscription'; + this._statusBarSubscription.command = Commands.ShowAccountView; + + if (account?.verified === false) { + this._statusBarSubscription.text = `$(warning) ${effective.name} (Unverified)`; + this._statusBarSubscription.backgroundColor = new ThemeColor( + 'statusBarItem.warningBackground' satisfies CoreColors, + ); + this._statusBarSubscription.tooltip = new MarkdownString( + trial + ? `**Please verify your email**\n\nYou must verify your email before you can start your **${effective.name}** trial.\n\nClick for details` + : `**Please verify your email**\n\nYou must verify your email before you can use Pro features on privately hosted repos.\n\nClick for details`, + true, + ); + } else { + const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); + const isReactivatedTrial = + state === SubscriptionState.FreePlusInTrial && effective.trialReactivationCount > 0; + + this._statusBarSubscription.text = `${effective.name} (Trial)`; + this._statusBarSubscription.tooltip = new MarkdownString( + `${ + isReactivatedTrial + ? `[See what's new](https://help.gitkraken.com/gitlens/gitlens-release-notes-current/) with + ${pluralize('day', remaining ?? 0, { + infix: ' more ', + })} + in your **${effective.name}** trial.` + : `You have ${pluralize('day', remaining ?? 0)} remaining in your **${effective.name}** trial.` + } Once your trial ends, you'll need a paid plan to continue using ✨ features.\n\nTry our + [other developer tools](https://www.gitkraken.com/suite) also included in your trial.`, + true, + ); + } + + this._statusBarSubscription.show(); + } +} + +function flattenSubscription(subscription: Optional | undefined, prefix?: string) { + if (subscription == null) return {}; + + return { + ...flatten(subscription.account, { + arrays: 'join', + prefix: `${prefix ? `${prefix}.` : ''}account`, + skipPaths: ['name', 'email'], + skipNulls: true, + stringify: true, + }), + ...flatten(subscription.plan, { + prefix: `${prefix ? `${prefix}.` : ''}subscription`, + skipPaths: ['actual.name', 'effective.name'], + skipNulls: true, + stringify: true, + }), + ...flatten(subscription.previewTrial, { + prefix: `${prefix ? `${prefix}.` : ''}subscription.previewTrial`, + skipPaths: ['actual.name', 'effective.name'], + skipNulls: true, + stringify: true, + }), + 'subscription.state': subscription.state, + }; +} diff --git a/src/plus/gk/authenticationConnection.ts b/src/plus/gk/authenticationConnection.ts deleted file mode 100644 index b67e567..0000000 --- a/src/plus/gk/authenticationConnection.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { CancellationToken, Disposable, StatusBarItem } from 'vscode'; -import { CancellationTokenSource, env, StatusBarAlignment, Uri, window } from 'vscode'; -import { uuid } from '@env/crypto'; -import type { Response } from '@env/fetch'; -import type { Container } from '../../container'; -import { debug } from '../../system/decorators/log'; -import type { DeferredEvent, DeferredEventExecutor } from '../../system/event'; -import { promisifyDeferred } from '../../system/event'; -import { Logger } from '../../system/logger'; -import { getLogScope } from '../../system/logger.scope'; -import type { ServerConnection } from './serverConnection'; - -export const AuthenticationUriPathPrefix = 'did-authenticate'; - -interface AccountInfo { - id: string; - accountName: string; -} - -export class AuthenticationConnection implements Disposable { - private _cancellationSource: CancellationTokenSource | undefined; - private _deferredCodeExchanges = new Map>(); - private _pendingStates = new Map(); - private _statusBarItem: StatusBarItem | undefined; - - constructor( - private readonly container: Container, - private readonly connection: ServerConnection, - ) {} - - dispose() {} - - abort(): Promise { - if (this._cancellationSource == null) return Promise.resolve(); - - this._cancellationSource.cancel(); - // This should allow the current auth request to abort before continuing - return new Promise(resolve => setTimeout(resolve, 50)); - } - - @debug({ args: false }) - async getAccountInfo(token: string): Promise { - const scope = getLogScope(); - - let rsp: Response; - try { - rsp = await this.connection.fetchApi('user', undefined, token); - } catch (ex) { - Logger.error(ex, scope); - throw ex; - } - - if (!rsp.ok) { - Logger.error(undefined, `Getting account info failed: (${rsp.status}) ${rsp.statusText}`); - throw new Error(rsp.statusText); - } - - const json: { id: string; username: string } = await rsp.json(); - return { id: json.id, accountName: json.username }; - } - - @debug() - async login(scopes: string[], scopeKey: string): Promise { - this.updateStatusBarItem(true); - - // Include a state parameter here to prevent CSRF attacks - const gkstate = uuid(); - const existingStates = this._pendingStates.get(scopeKey) ?? []; - this._pendingStates.set(scopeKey, [...existingStates, gkstate]); - - const callbackUri = await env.asExternalUri( - Uri.parse( - `${env.uriScheme}://${this.container.context.extension.id}/${AuthenticationUriPathPrefix}?gkstate=${gkstate}`, - ), - ); - - const uri = this.connection.getAccountsUri( - 'register', - `${scopes.includes('gitlens') ? 'referrer=gitlens&' : ''}pass-token=true&return-url=${encodeURIComponent( - callbackUri.toString(), - )}`, - ); - void (await env.openExternal(uri)); - - // Ensure there is only a single listener for the URI callback, in case the user starts the login process multiple times before completing it - let deferredCodeExchange = this._deferredCodeExchanges.get(scopeKey); - if (deferredCodeExchange == null) { - deferredCodeExchange = promisifyDeferred( - this.container.uri.onDidReceiveAuthenticationUri, - this.getUriHandlerDeferredExecutor(scopeKey), - ); - this._deferredCodeExchanges.set(scopeKey, deferredCodeExchange); - } - - if (this._cancellationSource != null) { - this._cancellationSource.cancel(); - this._cancellationSource = undefined; - } - - this._cancellationSource = new CancellationTokenSource(); - - void this.openCompletionInputFallback(this._cancellationSource.token); - - return Promise.race([ - deferredCodeExchange.promise, - new Promise( - (_, reject) => - // eslint-disable-next-line prefer-promise-reject-errors - this._cancellationSource?.token.onCancellationRequested(() => reject('Cancelled')), - ), - new Promise((_, reject) => setTimeout(reject, 120000, 'Cancelled')), - ]).finally(() => { - this._cancellationSource?.cancel(); - this._cancellationSource = undefined; - - this._pendingStates.delete(scopeKey); - deferredCodeExchange?.cancel(); - this._deferredCodeExchanges.delete(scopeKey); - this.updateStatusBarItem(false); - }); - } - - private async openCompletionInputFallback(cancellationToken: CancellationToken) { - const input = window.createInputBox(); - input.ignoreFocusOut = true; - - const disposables: Disposable[] = []; - - try { - if (cancellationToken.isCancellationRequested) return; - - const uri = await new Promise(resolve => { - disposables.push( - cancellationToken.onCancellationRequested(() => input.hide()), - input.onDidHide(() => resolve(undefined)), - input.onDidChangeValue(e => { - if (!e) { - input.validationMessage = undefined; - return; - } - - try { - const uri = Uri.parse(e.trim()); - if (uri.scheme && uri.scheme !== 'file') { - input.validationMessage = undefined; - return; - } - } catch {} - - input.validationMessage = 'Please enter a valid authorization URL'; - }), - input.onDidAccept(() => resolve(Uri.parse(input.value.trim()))), - ); - - input.title = 'GitKraken Sign In'; - input.placeholder = 'Please enter the provided authorization URL'; - input.prompt = 'If the auto-redirect fails, paste the authorization URL'; - - input.show(); - }); - - if (uri != null) { - this.container.uri.handleUri(uri); - } - } finally { - input.dispose(); - disposables.forEach(d => void d.dispose()); - } - } - - private getUriHandlerDeferredExecutor(_scopeKey: string): DeferredEventExecutor { - return (uri: Uri, resolve, reject) => { - // TODO: We should really support a code to token exchange, but just return the token from the query string - // await this.exchangeCodeForToken(uri.query); - // As the backend still doesn't implement yet the code to token exchange, we just validate the state returned - const queryParams: URLSearchParams = new URLSearchParams(uri.query); - - const acceptedStates = this._pendingStates.get(_scopeKey); - const state = queryParams.get('gkstate'); - - if (acceptedStates == null || !state || !acceptedStates.includes(state)) { - // A common scenario of this happening is if you: - // 1. Trigger a sign in with one set of scopes - // 2. Before finishing 1, you trigger a sign in with a different set of scopes - // In this scenario we should just return and wait for the next UriHandler event - // to run as we are probably still waiting on the user to hit 'Continue' - Logger.log('State not found in accepted state. Skipping this execution...'); - return; - } - - const accessToken = queryParams.get('access-token'); - const code = queryParams.get('code'); - const token = accessToken ?? code; - - if (token == null) { - reject('Token not returned'); - } else { - resolve(token); - } - }; - } - - private updateStatusBarItem(signingIn?: boolean) { - if (signingIn && this._statusBarItem == null) { - this._statusBarItem = window.createStatusBarItem('gitlens.plus.signIn', StatusBarAlignment.Left); - this._statusBarItem.name = 'GitKraken Sign in'; - this._statusBarItem.text = 'Signing in to GitKraken...'; - this._statusBarItem.show(); - } - - if (!signingIn && this._statusBarItem != null) { - this._statusBarItem.dispose(); - this._statusBarItem = undefined; - } - } -} diff --git a/src/plus/gk/authenticationProvider.ts b/src/plus/gk/authenticationProvider.ts deleted file mode 100644 index 9400415..0000000 --- a/src/plus/gk/authenticationProvider.ts +++ /dev/null @@ -1,295 +0,0 @@ -import type { - AuthenticationProvider, - AuthenticationProviderAuthenticationSessionsChangeEvent, - AuthenticationSession, -} from 'vscode'; -import { authentication, Disposable, EventEmitter, window } from 'vscode'; -import { uuid } from '@env/crypto'; -import type { Container, Environment } from '../../container'; -import { debug } from '../../system/decorators/log'; -import { Logger } from '../../system/logger'; -import { getLogScope, setLogScopeExit } from '../../system/logger.scope'; -import { AuthenticationConnection } from './authenticationConnection'; -import type { ServerConnection } from './serverConnection'; - -interface StoredSession { - id: string; - accessToken: string; - account?: { - label?: string; - displayName?: string; - id: string; - }; - scopes: string[]; -} - -export const authenticationProviderId = 'gitlens+'; -export const authenticationProviderScopes = ['gitlens']; -const authenticationLabel = 'GitKraken: GitLens'; - -export class AccountAuthenticationProvider implements AuthenticationProvider, Disposable { - private _onDidChangeSessions = new EventEmitter(); - get onDidChangeSessions() { - return this._onDidChangeSessions.event; - } - - private readonly _disposable: Disposable; - private readonly _authConnection: AuthenticationConnection; - private _sessionsPromise: Promise; - - constructor( - private readonly container: Container, - connection: ServerConnection, - ) { - this._authConnection = new AuthenticationConnection(container, connection); - - // Contains the current state of the sessions we have available. - this._sessionsPromise = this.getSessionsFromStorage(); - - this._disposable = Disposable.from( - this._authConnection, - authentication.registerAuthenticationProvider(authenticationProviderId, authenticationLabel, this, { - supportsMultipleAccounts: false, - }), - this.container.storage.onDidChangeSecrets(() => this.checkForUpdates()), - ); - } - - dispose() { - this._disposable.dispose(); - } - - private get secretStorageKey(): `gitlens.plus.auth:${Environment}` { - return `gitlens.plus.auth:${this.container.env}`; - } - - abort(): Promise { - return this._authConnection.abort(); - } - - @debug() - public async createSession(scopes: string[]): Promise { - const scope = getLogScope(); - - // Ensure that the scopes are sorted consistently (since we use them for matching and order doesn't matter) - scopes = scopes.sort(); - const scopesKey = getScopesKey(scopes); - - try { - const token = await this._authConnection.login(scopes, scopesKey); - const session = await this.createSessionForToken(token, scopes); - - const sessions = await this._sessionsPromise; - const sessionIndex = sessions.findIndex(s => s.id === session.id || getScopesKey(s.scopes) === scopesKey); - if (sessionIndex > -1) { - sessions.splice(sessionIndex, 1, session); - } else { - sessions.push(session); - } - await this.storeSessions(sessions); - - this._onDidChangeSessions.fire({ added: [session], removed: [], changed: [] }); - - return session; - } catch (ex) { - // If login was cancelled, do not notify user. - if (ex === 'Cancelled') throw ex; - - Logger.error(ex, scope); - void window.showErrorMessage(`Unable to sign in to GitKraken: ${ex}`); - throw ex; - } - } - - @debug() - async getSessions(scopes?: string[]): Promise { - const scope = getLogScope(); - - scopes = scopes?.sort(); - const scopesKey = getScopesKey(scopes); - - const sessions = await this._sessionsPromise; - const filtered = scopes != null ? sessions.filter(s => getScopesKey(s.scopes) === scopesKey) : sessions; - - setLogScopeExit(scope, ` \u2022 Found ${filtered.length} sessions`); - - return filtered; - } - - @debug() - public async removeSession(id: string) { - const scope = getLogScope(); - - try { - const sessions = await this._sessionsPromise; - const sessionIndex = sessions.findIndex(session => session.id === id); - if (sessionIndex === -1) { - Logger.log(`Unable to remove session ${id}; Not found`); - return; - } - - const session = sessions[sessionIndex]; - sessions.splice(sessionIndex, 1); - - await this.storeSessions(sessions); - - this._onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); - } catch (ex) { - Logger.error(ex, scope); - void window.showErrorMessage(`Unable to sign out of GitKraken: ${ex}`); - throw ex; - } - } - - @debug() - public async removeSessionsByScopes(scopes?: string[]) { - const scope = getLogScope(); - - try { - scopes = scopes?.sort(); - const scopesKey = getScopesKey(scopes); - - const removed: AuthenticationSession[] = []; - - let index = 0; - - const sessions = await this._sessionsPromise; - - for (const session of sessions) { - if (getScopesKey(session.scopes) !== scopesKey) { - index++; - continue; - } - - sessions.splice(index, 1); - removed.push(session); - } - - if (removed.length === 0) return; - - await this.storeSessions(sessions); - - this._onDidChangeSessions.fire({ added: [], removed: removed, changed: [] }); - } catch (ex) { - Logger.error(ex, scope); - void window.showErrorMessage(`Unable to sign out of GitKraken: ${ex}`); - throw ex; - } - } - - private async checkForUpdates() { - const previousSessions = await this._sessionsPromise; - this._sessionsPromise = this.getSessionsFromStorage(); - const storedSessions = await this._sessionsPromise; - - const added: AuthenticationSession[] = []; - const removed: AuthenticationSession[] = []; - - for (const session of storedSessions) { - if (previousSessions.some(s => s.id === session.id)) continue; - - // Another window added a session, so let our window know about it - added.push(session); - } - - for (const session of previousSessions) { - if (storedSessions.some(s => s.id === session.id)) continue; - - // Another window has removed this session (or logged out), so let our window know about it - removed.push(session); - } - - if (added.length || removed.length) { - Logger.debug(`Firing sessions changed event; added=${added.length}, removed=${removed.length}`); - this._onDidChangeSessions.fire({ added: added, removed: removed, changed: [] }); - } - } - - private async createSessionForToken(token: string, scopes: string[]): Promise { - const userInfo = await this._authConnection.getAccountInfo(token); - return { - id: uuid(), - accessToken: token, - account: { label: userInfo.accountName, id: userInfo.id }, - scopes: scopes, - }; - } - - private async getSessionsFromStorage(): Promise { - let storedSessions: StoredSession[]; - - try { - const sessionsJSON = await this.container.storage.getSecret(this.secretStorageKey); - if (!sessionsJSON || sessionsJSON === '[]') return []; - - try { - storedSessions = JSON.parse(sessionsJSON); - } catch (ex) { - try { - await this.container.storage.deleteSecret(this.secretStorageKey); - } catch {} - - throw ex; - } - } catch (ex) { - Logger.error(ex, 'Unable to read sessions from storage'); - return []; - } - - const sessionPromises = storedSessions.map(async (session: StoredSession) => { - const scopesKey = getScopesKey(session.scopes); - - Logger.debug(`Read session from storage with scopes=${scopesKey}`); - - let userInfo: { id: string; accountName: string } | undefined; - if (session.account == null) { - try { - userInfo = await this._authConnection.getAccountInfo(session.accessToken); - Logger.debug(`Verified session with scopes=${scopesKey}`); - } catch (ex) { - // Remove sessions that return unauthorized response - if (ex.message === 'Unauthorized') return undefined; - } - } - - return { - id: session.id, - account: { - label: - session.account != null - ? session.account.label ?? session.account.displayName ?? '' - : userInfo?.accountName ?? '', - id: session.account?.id ?? userInfo?.id ?? '', - }, - scopes: session.scopes, - accessToken: session.accessToken, - }; - }); - - const verifiedSessions = (await Promise.allSettled(sessionPromises)) - .filter(p => p.status === 'fulfilled') - .map(p => (p as PromiseFulfilledResult).value) - .filter((p?: T): p is T => Boolean(p)); - - Logger.debug(`Found ${verifiedSessions.length} verified sessions`); - if (verifiedSessions.length !== storedSessions.length) { - await this.storeSessions(verifiedSessions); - } - return verifiedSessions; - } - - private async storeSessions(sessions: AuthenticationSession[]): Promise { - try { - this._sessionsPromise = Promise.resolve(sessions); - await this.container.storage.storeSecret(this.secretStorageKey, JSON.stringify(sessions)); - } catch (ex) { - Logger.error(ex, `Unable to store ${sessions.length} sessions`); - } - } -} - -function getScopesKey(scopes: readonly string[]): string; -function getScopesKey(scopes: readonly string[] | undefined): string | undefined; -function getScopesKey(scopes: readonly string[] | undefined): string | undefined { - return scopes?.join('|'); -} diff --git a/src/plus/gk/checkin.ts b/src/plus/gk/checkin.ts new file mode 100644 index 0000000..9a5b4d6 --- /dev/null +++ b/src/plus/gk/checkin.ts @@ -0,0 +1,189 @@ +import type { Subscription } from './account/subscription'; +import { getSubscriptionPlan, getSubscriptionPlanPriority, SubscriptionPlanId } from './account/subscription'; + +export interface GKCheckInResponse { + readonly user: GKUser; + readonly licenses: { + readonly paidLicenses: Record; + readonly effectiveLicenses: Record; + }; + readonly orgIds?: string[]; +} + +export interface GKUser { + readonly id: string; + readonly name: string; + readonly email: string; + readonly status: 'activated' | 'pending'; + readonly createdDate: string; + readonly firstGitLensCheckIn?: string; +} + +export interface GKLicense { + readonly latestStatus: 'active' | 'canceled' | 'cancelled' | 'expired' | 'in_trial' | 'non_renewing' | 'trial'; + readonly latestStartDate: string; + readonly latestEndDate: string; + readonly organizationId: string | undefined; + readonly reactivationCount?: number; +} + +export type GKLicenseType = + | 'gitlens-pro' + | 'gitlens-teams' + | 'gitlens-hosted-enterprise' + | 'gitlens-self-hosted-enterprise' + | 'gitlens-standalone-enterprise' + | 'bundle-pro' + | 'bundle-teams' + | 'bundle-hosted-enterprise' + | 'bundle-self-hosted-enterprise' + | 'bundle-standalone-enterprise' + | 'gitkraken_v1-pro' + | 'gitkraken_v1-teams' + | 'gitkraken_v1-hosted-enterprise' + | 'gitkraken_v1-self-hosted-enterprise' + | 'gitkraken_v1-standalone-enterprise'; + +export function getSubscriptionFromCheckIn(data: GKCheckInResponse): Partial { + const account: Subscription['account'] = { + id: data.user.id, + name: data.user.name, + email: data.user.email, + verified: data.user.status === 'activated', + createdOn: data.user.createdDate, + organizationIds: data.orgIds ?? [], + }; + + const effectiveLicenses = Object.entries(data.licenses.effectiveLicenses) as [GKLicenseType, GKLicense][]; + const paidLicenses = Object.entries(data.licenses.paidLicenses) as [GKLicenseType, GKLicense][]; + + let actual: Subscription['plan']['actual'] | undefined; + if (paidLicenses.length > 0) { + if (paidLicenses.length > 1) { + paidLicenses.sort( + (a, b) => + getSubscriptionPlanPriority(convertLicenseTypeToPlanId(b[0])) + + licenseStatusPriority(b[1].latestStatus) - + (getSubscriptionPlanPriority(convertLicenseTypeToPlanId(a[0])) + + licenseStatusPriority(a[1].latestStatus)), + ); + } + + const [licenseType, license] = paidLicenses[0]; + actual = getSubscriptionPlan( + convertLicenseTypeToPlanId(licenseType), + isBundleLicenseType(licenseType), + license.reactivationCount ?? 0, + license.organizationId, + new Date(license.latestStartDate), + new Date(license.latestEndDate), + license.latestStatus === 'cancelled', + ); + } + + if (actual == null) { + actual = getSubscriptionPlan( + SubscriptionPlanId.FreePlus, + false, + 0, + undefined, + data.user.firstGitLensCheckIn != null + ? new Date(data.user.firstGitLensCheckIn) + : data.user.createdDate != null + ? new Date(data.user.createdDate) + : undefined, + ); + } + + let effective: Subscription['plan']['effective'] | undefined; + if (effectiveLicenses.length > 0) { + if (effectiveLicenses.length > 1) { + effectiveLicenses.sort( + (a, b) => + getSubscriptionPlanPriority(convertLicenseTypeToPlanId(b[0])) + + licenseStatusPriority(b[1].latestStatus) - + (getSubscriptionPlanPriority(convertLicenseTypeToPlanId(a[0])) + + licenseStatusPriority(a[1].latestStatus)), + ); + } + + const [licenseType, license] = effectiveLicenses[0]; + effective = getSubscriptionPlan( + convertLicenseTypeToPlanId(licenseType), + isBundleLicenseType(licenseType), + license.reactivationCount ?? 0, + license.organizationId, + new Date(license.latestStartDate), + new Date(license.latestEndDate), + license.latestStatus === 'cancelled', + ); + } + + if (effective == null || getSubscriptionPlanPriority(actual.id) >= getSubscriptionPlanPriority(effective.id)) { + effective = { ...actual }; + } + + return { + plan: { + actual: actual, + effective: effective, + }, + account: account, + }; +} + +function convertLicenseTypeToPlanId(licenseType: GKLicenseType): SubscriptionPlanId { + switch (licenseType) { + case 'gitlens-pro': + case 'bundle-pro': + case 'gitkraken_v1-pro': + return SubscriptionPlanId.Pro; + case 'gitlens-teams': + case 'bundle-teams': + case 'gitkraken_v1-teams': + return SubscriptionPlanId.Teams; + case 'gitlens-hosted-enterprise': + case 'gitlens-self-hosted-enterprise': + case 'gitlens-standalone-enterprise': + case 'bundle-hosted-enterprise': + case 'bundle-self-hosted-enterprise': + case 'bundle-standalone-enterprise': + case 'gitkraken_v1-hosted-enterprise': + case 'gitkraken_v1-self-hosted-enterprise': + case 'gitkraken_v1-standalone-enterprise': + return SubscriptionPlanId.Enterprise; + default: + return SubscriptionPlanId.FreePlus; + } +} + +function isBundleLicenseType(licenseType: GKLicenseType): boolean { + switch (licenseType) { + case 'bundle-pro': + case 'bundle-teams': + case 'bundle-hosted-enterprise': + case 'bundle-self-hosted-enterprise': + case 'bundle-standalone-enterprise': + return true; + default: + return false; + } +} + +function licenseStatusPriority(status: GKLicense['latestStatus']): number { + switch (status) { + case 'active': + return 100; + case 'expired': + case 'cancelled': + return -100; + case 'in_trial': + case 'trial': + return 1; + case 'canceled': + case 'non_renewing': + return 0; + default: + return -200; + } +} diff --git a/src/plus/gk/subscription/checkin.ts b/src/plus/gk/subscription/checkin.ts deleted file mode 100644 index c8b0006..0000000 --- a/src/plus/gk/subscription/checkin.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { Subscription } from './subscription'; -import { getSubscriptionPlan, getSubscriptionPlanPriority, SubscriptionPlanId } from './subscription'; - -export interface GKCheckInResponse { - readonly user: GKUser; - readonly licenses: { - readonly paidLicenses: Record; - readonly effectiveLicenses: Record; - }; - readonly orgIds?: string[]; -} - -export interface GKUser { - readonly id: string; - readonly name: string; - readonly email: string; - readonly status: 'activated' | 'pending'; - readonly createdDate: string; - readonly firstGitLensCheckIn?: string; -} - -export interface GKLicense { - readonly latestStatus: 'active' | 'canceled' | 'cancelled' | 'expired' | 'in_trial' | 'non_renewing' | 'trial'; - readonly latestStartDate: string; - readonly latestEndDate: string; - readonly organizationId: string | undefined; - readonly reactivationCount?: number; -} - -export type GKLicenseType = - | 'gitlens-pro' - | 'gitlens-teams' - | 'gitlens-hosted-enterprise' - | 'gitlens-self-hosted-enterprise' - | 'gitlens-standalone-enterprise' - | 'bundle-pro' - | 'bundle-teams' - | 'bundle-hosted-enterprise' - | 'bundle-self-hosted-enterprise' - | 'bundle-standalone-enterprise' - | 'gitkraken_v1-pro' - | 'gitkraken_v1-teams' - | 'gitkraken_v1-hosted-enterprise' - | 'gitkraken_v1-self-hosted-enterprise' - | 'gitkraken_v1-standalone-enterprise'; - -export function getSubscriptionFromCheckIn(data: GKCheckInResponse): Partial { - const account: Subscription['account'] = { - id: data.user.id, - name: data.user.name, - email: data.user.email, - verified: data.user.status === 'activated', - createdOn: data.user.createdDate, - organizationIds: data.orgIds ?? [], - }; - - const effectiveLicenses = Object.entries(data.licenses.effectiveLicenses) as [GKLicenseType, GKLicense][]; - const paidLicenses = Object.entries(data.licenses.paidLicenses) as [GKLicenseType, GKLicense][]; - - let actual: Subscription['plan']['actual'] | undefined; - if (paidLicenses.length > 0) { - if (paidLicenses.length > 1) { - paidLicenses.sort( - (a, b) => - getSubscriptionPlanPriority(convertLicenseTypeToPlanId(b[0])) + - licenseStatusPriority(b[1].latestStatus) - - (getSubscriptionPlanPriority(convertLicenseTypeToPlanId(a[0])) + - licenseStatusPriority(a[1].latestStatus)), - ); - } - - const [licenseType, license] = paidLicenses[0]; - actual = getSubscriptionPlan( - convertLicenseTypeToPlanId(licenseType), - isBundleLicenseType(licenseType), - license.reactivationCount ?? 0, - license.organizationId, - new Date(license.latestStartDate), - new Date(license.latestEndDate), - license.latestStatus === 'cancelled', - ); - } - - if (actual == null) { - actual = getSubscriptionPlan( - SubscriptionPlanId.FreePlus, - false, - 0, - undefined, - data.user.firstGitLensCheckIn != null - ? new Date(data.user.firstGitLensCheckIn) - : data.user.createdDate != null - ? new Date(data.user.createdDate) - : undefined, - ); - } - - let effective: Subscription['plan']['effective'] | undefined; - if (effectiveLicenses.length > 0) { - if (effectiveLicenses.length > 1) { - effectiveLicenses.sort( - (a, b) => - getSubscriptionPlanPriority(convertLicenseTypeToPlanId(b[0])) + - licenseStatusPriority(b[1].latestStatus) - - (getSubscriptionPlanPriority(convertLicenseTypeToPlanId(a[0])) + - licenseStatusPriority(a[1].latestStatus)), - ); - } - - const [licenseType, license] = effectiveLicenses[0]; - effective = getSubscriptionPlan( - convertLicenseTypeToPlanId(licenseType), - isBundleLicenseType(licenseType), - license.reactivationCount ?? 0, - license.organizationId, - new Date(license.latestStartDate), - new Date(license.latestEndDate), - license.latestStatus === 'cancelled', - ); - } - - if (effective == null || getSubscriptionPlanPriority(actual.id) >= getSubscriptionPlanPriority(effective.id)) { - effective = { ...actual }; - } - - return { - plan: { - actual: actual, - effective: effective, - }, - account: account, - }; -} - -function convertLicenseTypeToPlanId(licenseType: GKLicenseType): SubscriptionPlanId { - switch (licenseType) { - case 'gitlens-pro': - case 'bundle-pro': - case 'gitkraken_v1-pro': - return SubscriptionPlanId.Pro; - case 'gitlens-teams': - case 'bundle-teams': - case 'gitkraken_v1-teams': - return SubscriptionPlanId.Teams; - case 'gitlens-hosted-enterprise': - case 'gitlens-self-hosted-enterprise': - case 'gitlens-standalone-enterprise': - case 'bundle-hosted-enterprise': - case 'bundle-self-hosted-enterprise': - case 'bundle-standalone-enterprise': - case 'gitkraken_v1-hosted-enterprise': - case 'gitkraken_v1-self-hosted-enterprise': - case 'gitkraken_v1-standalone-enterprise': - return SubscriptionPlanId.Enterprise; - default: - return SubscriptionPlanId.FreePlus; - } -} - -function isBundleLicenseType(licenseType: GKLicenseType): boolean { - switch (licenseType) { - case 'bundle-pro': - case 'bundle-teams': - case 'bundle-hosted-enterprise': - case 'bundle-self-hosted-enterprise': - case 'bundle-standalone-enterprise': - return true; - default: - return false; - } -} - -function licenseStatusPriority(status: GKLicense['latestStatus']): number { - switch (status) { - case 'active': - return 100; - case 'expired': - case 'cancelled': - return -100; - case 'in_trial': - case 'trial': - return 1; - case 'canceled': - case 'non_renewing': - return 0; - default: - return -200; - } -} diff --git a/src/plus/gk/subscription/subscription.ts b/src/plus/gk/subscription/subscription.ts deleted file mode 100644 index 06aa94a..0000000 --- a/src/plus/gk/subscription/subscription.ts +++ /dev/null @@ -1,257 +0,0 @@ -// NOTE@eamodio This file is referenced in the webviews to we can't use anything vscode or other imports that aren't available in the webviews -import { getDateDifference } from '../../../system/date'; - -export const enum SubscriptionPlanId { - Free = 'free', - FreePlus = 'free+', - Pro = 'pro', - Teams = 'teams', - Enterprise = 'enterprise', -} - -export type FreeSubscriptionPlans = Extract; -export type PaidSubscriptionPlans = Exclude; -export type RequiredSubscriptionPlans = Exclude; - -export interface Subscription { - readonly plan: { - readonly actual: SubscriptionPlan; - readonly effective: SubscriptionPlan; - }; - account: SubscriptionAccount | undefined; - previewTrial?: SubscriptionPreviewTrial; - - state: SubscriptionState; - - lastValidatedAt?: number; -} - -export interface SubscriptionPlan { - readonly id: SubscriptionPlanId; - readonly name: string; - readonly bundle: boolean; - readonly trialReactivationCount: number; - readonly cancelled: boolean; - readonly startedOn: string; - readonly expiresOn?: string | undefined; - readonly organizationId: string | undefined; -} - -export interface SubscriptionAccount { - readonly id: string; - readonly name: string; - readonly email: string | undefined; - readonly verified: boolean; - readonly createdOn: string; - readonly organizationIds: string[]; -} - -export interface SubscriptionPreviewTrial { - readonly startedOn: string; - readonly expiresOn: string; -} - -export const enum SubscriptionState { - /** Indicates a user who hasn't verified their email address yet */ - VerificationRequired = -1, - /** Indicates a Free user who hasn't yet started the preview trial */ - Free = 0, - /** Indicates a Free user who is in preview trial */ - FreeInPreviewTrial, - /** Indicates a Free user who's preview has expired trial */ - FreePreviewTrialExpired, - /** Indicates a Free+ user with a completed trial */ - FreePlusInTrial, - /** Indicates a Free+ user who's trial has expired */ - FreePlusTrialExpired, - /** Indicates a Paid user */ - Paid, -} - -export function computeSubscriptionState(subscription: Optional): SubscriptionState { - const { - account, - plan: { actual, effective }, - previewTrial: preview, - } = subscription; - - if (account?.verified === false) return SubscriptionState.VerificationRequired; - - if (actual.id === effective.id) { - switch (effective.id) { - case SubscriptionPlanId.Free: - return preview == null ? SubscriptionState.Free : SubscriptionState.FreePreviewTrialExpired; - - case SubscriptionPlanId.FreePlus: - return SubscriptionState.FreePlusTrialExpired; - - case SubscriptionPlanId.Pro: - case SubscriptionPlanId.Teams: - case SubscriptionPlanId.Enterprise: - return SubscriptionState.Paid; - } - } - - switch (effective.id) { - case SubscriptionPlanId.Free: - return preview == null ? SubscriptionState.Free : SubscriptionState.FreeInPreviewTrial; - - case SubscriptionPlanId.FreePlus: - return SubscriptionState.FreePlusTrialExpired; - - case SubscriptionPlanId.Pro: - return actual.id === SubscriptionPlanId.Free - ? SubscriptionState.FreeInPreviewTrial - : SubscriptionState.FreePlusInTrial; - - case SubscriptionPlanId.Teams: - case SubscriptionPlanId.Enterprise: - return SubscriptionState.Paid; - } -} - -export function getSubscriptionPlan( - id: SubscriptionPlanId, - bundle: boolean, - trialReactivationCount: number, - organizationId: string | undefined, - startedOn?: Date, - expiresOn?: Date, - cancelled: boolean = false, -): SubscriptionPlan { - return { - id: id, - name: getSubscriptionPlanName(id), - bundle: bundle, - cancelled: cancelled, - organizationId: organizationId, - trialReactivationCount: trialReactivationCount, - startedOn: (startedOn ?? new Date()).toISOString(), - expiresOn: expiresOn != null ? expiresOn.toISOString() : undefined, - }; -} - -export function getSubscriptionPlanName(id: SubscriptionPlanId) { - switch (id) { - case SubscriptionPlanId.FreePlus: - return 'GitKraken Free'; - case SubscriptionPlanId.Pro: - return 'GitKraken Pro'; - case SubscriptionPlanId.Teams: - return 'GitKraken Teams'; - case SubscriptionPlanId.Enterprise: - return 'GitKraken Enterprise'; - case SubscriptionPlanId.Free: - default: - return 'GitKraken'; - } -} - -export function getSubscriptionStatePlanName(state: SubscriptionState | undefined, id: SubscriptionPlanId | undefined) { - switch (state) { - case SubscriptionState.FreePlusTrialExpired: - return getSubscriptionPlanName(SubscriptionPlanId.FreePlus); - case SubscriptionState.FreeInPreviewTrial: - return `${getSubscriptionPlanName(SubscriptionPlanId.Pro)} (Trial)`; - case SubscriptionState.FreePlusInTrial: - return `${getSubscriptionPlanName(id ?? SubscriptionPlanId.Pro)} (Trial)`; - case SubscriptionState.VerificationRequired: - return `GitKraken (Unverified)`; - case SubscriptionState.Paid: - return getSubscriptionPlanName(id ?? SubscriptionPlanId.Pro); - case SubscriptionState.Free: - case SubscriptionState.FreePreviewTrialExpired: - case null: - default: - return 'GitKraken'; - } -} - -const plansPriority = new Map([ - [undefined, -1], - [SubscriptionPlanId.Free, 0], - [SubscriptionPlanId.FreePlus, 1], - [SubscriptionPlanId.Pro, 2], - [SubscriptionPlanId.Teams, 3], - [SubscriptionPlanId.Enterprise, 4], -]); - -export function getSubscriptionPlanPriority(id: SubscriptionPlanId | undefined): number { - return plansPriority.get(id) ?? -1; -} - -export function getSubscriptionTimeRemaining( - subscription: Optional, - unit?: 'days' | 'hours' | 'minutes' | 'seconds', -): number | undefined { - return getTimeRemaining(subscription.plan.effective.expiresOn, unit); -} - -export function getTimeRemaining( - expiresOn: string | undefined, - unit?: 'days' | 'hours' | 'minutes' | 'seconds', -): number | undefined { - return expiresOn != null ? getDateDifference(Date.now(), new Date(expiresOn), unit) : undefined; -} - -export function isSubscriptionPaid(subscription: Optional): boolean { - return isSubscriptionPaidPlan(subscription.plan.effective.id); -} - -export function isSubscriptionPaidPlan(id: SubscriptionPlanId): id is PaidSubscriptionPlans { - return id !== SubscriptionPlanId.Free && id !== SubscriptionPlanId.FreePlus; -} - -export function isSubscriptionExpired(subscription: Optional): boolean { - const remaining = getSubscriptionTimeRemaining(subscription); - return remaining != null && remaining <= 0; -} - -export function isSubscriptionTrial(subscription: Optional): boolean { - return subscription.plan.actual.id !== subscription.plan.effective.id; -} - -export function isSubscriptionInProTrial(subscription: Optional): boolean { - if ( - subscription.account == null || - !isSubscriptionTrial(subscription) || - isSubscriptionPreviewTrialExpired(subscription) === false - ) { - return false; - } - - const remaining = getSubscriptionTimeRemaining(subscription); - return remaining != null ? remaining <= 0 : true; -} - -export function isSubscriptionPreviewTrialExpired(subscription: Optional): boolean | undefined { - const remaining = getTimeRemaining(subscription.previewTrial?.expiresOn); - return remaining != null ? remaining <= 0 : undefined; -} - -export function isSubscriptionStatePaidOrTrial(state: SubscriptionState | undefined): boolean { - if (state == null) return false; - return ( - state === SubscriptionState.Paid || - state === SubscriptionState.FreeInPreviewTrial || - state === SubscriptionState.FreePlusInTrial - ); -} - -export function isSubscriptionStateTrial(state: SubscriptionState | undefined): boolean { - if (state == null) return false; - return state === SubscriptionState.FreeInPreviewTrial || state === SubscriptionState.FreePlusInTrial; -} - -export function hasAccountFromSubscriptionState(state: SubscriptionState | undefined): boolean { - if (state == null) return false; - return ( - state !== SubscriptionState.Free && - state !== SubscriptionState.FreePreviewTrialExpired && - state !== SubscriptionState.FreeInPreviewTrial - ); -} - -export function assertSubscriptionState( - subscription: Optional, -): asserts subscription is Subscription {} diff --git a/src/plus/gk/subscription/subscriptionService.ts b/src/plus/gk/subscription/subscriptionService.ts deleted file mode 100644 index cf6306a..0000000 --- a/src/plus/gk/subscription/subscriptionService.ts +++ /dev/null @@ -1,1018 +0,0 @@ -import type { - AuthenticationProviderAuthenticationSessionsChangeEvent, - AuthenticationSession, - CancellationToken, - Event, - MessageItem, - StatusBarItem, -} from 'vscode'; -import { - authentication, - CancellationTokenSource, - version as codeVersion, - Disposable, - env, - EventEmitter, - MarkdownString, - ProgressLocation, - StatusBarAlignment, - ThemeColor, - window, -} from 'vscode'; -import { getPlatform } from '@env/platform'; -import type { CoreColors } from '../../../constants'; -import { Commands } from '../../../constants'; -import type { Container } from '../../../container'; -import { AccountValidationError } from '../../../errors'; -import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; -import { executeCommand, registerCommand } from '../../../system/command'; -import { configuration } from '../../../system/configuration'; -import { setContext } from '../../../system/context'; -import { createFromDateDelta } from '../../../system/date'; -import { gate } from '../../../system/decorators/gate'; -import { debug, log } from '../../../system/decorators/log'; -import type { Deferrable } from '../../../system/function'; -import { debounce, once } from '../../../system/function'; -import { Logger } from '../../../system/logger'; -import { getLogScope } from '../../../system/logger.scope'; -import { flatten } from '../../../system/object'; -import { pluralize } from '../../../system/string'; -import { openWalkthrough } from '../../../system/utils'; -import { satisfies } from '../../../system/version'; -import { authenticationProviderId, authenticationProviderScopes } from '../authenticationProvider'; -import type { ServerConnection } from '../serverConnection'; -import { ensurePlusFeaturesEnabled } from '../utils'; -import type { GKCheckInResponse } from './checkin'; -import { getSubscriptionFromCheckIn } from './checkin'; -import type { Subscription } from './subscription'; -import { - assertSubscriptionState, - computeSubscriptionState, - getSubscriptionPlan, - getSubscriptionPlanName, - getSubscriptionTimeRemaining, - getTimeRemaining, - isSubscriptionExpired, - isSubscriptionInProTrial, - isSubscriptionPaid, - isSubscriptionTrial, - SubscriptionPlanId, - SubscriptionState, -} from './subscription'; - -export interface SubscriptionChangeEvent { - readonly current: Subscription; - readonly previous: Subscription; - readonly etag: number; -} - -export class SubscriptionService implements Disposable { - private _onDidChange = new EventEmitter(); - get onDidChange(): Event { - return this._onDidChange.event; - } - - private _disposable: Disposable; - private _subscription!: Subscription; - private _statusBarSubscription: StatusBarItem | undefined; - private _validationTimer: ReturnType | undefined; - - constructor( - private readonly container: Container, - private readonly connection: ServerConnection, - previousVersion: string | undefined, - ) { - this._disposable = Disposable.from( - once(container.onReady)(this.onReady, this), - this.container.accountAuthentication.onDidChangeSessions( - e => setTimeout(() => this.onAuthenticationChanged(e), 0), - this, - ), - configuration.onDidChange(e => { - if (configuration.changed(e, 'plusFeatures')) { - this.updateContext(); - } - }), - ); - - const subscription = this.getStoredSubscription(); - // Resets the preview trial state on the upgrade to 14.0 - if (subscription != null && satisfies(previousVersion, '< 14.0')) { - subscription.previewTrial = undefined; - } - - this.changeSubscription(subscription, { silent: true }); - setTimeout(() => void this.ensureSession(false), 10000); - } - - dispose(): void { - this._statusBarSubscription?.dispose(); - - this._disposable.dispose(); - } - - private async onAuthenticationChanged(e: AuthenticationProviderAuthenticationSessionsChangeEvent) { - let session = this._session; - if (session == null && this._sessionPromise != null) { - session = await this._sessionPromise; - } - - if (session != null && e.removed?.some(s => s.id === session!.id)) { - this._session = undefined; - this._sessionPromise = undefined; - void this.logout(); - return; - } - - const updated = e.added?.[0] ?? e.changed?.[0]; - if (updated == null) return; - - if (updated.id === session?.id && updated.accessToken === session?.accessToken) { - return; - } - - this._session = session; - void this.validate({ force: true }); - } - - private _etag: number = 0; - get etag(): number { - return this._etag; - } - - private onReady() { - this._disposable = Disposable.from( - this._disposable, - this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), - ...this.registerCommands(), - ); - this.updateContext(); - } - - private onRepositoriesChanged(_e: RepositoriesChangeEvent): void { - this.updateContext(); - } - - private registerCommands(): Disposable[] { - void this.container.viewCommands; - - return [ - registerCommand(Commands.PlusLoginOrSignUp, () => this.loginOrSignUp()), - registerCommand(Commands.PlusLogout, () => this.logout()), - - registerCommand(Commands.PlusStartPreviewTrial, () => this.startPreviewTrial()), - registerCommand(Commands.PlusManage, () => this.manage()), - registerCommand(Commands.PlusPurchase, () => this.purchase()), - - registerCommand(Commands.PlusResendVerification, () => this.resendVerification()), - registerCommand(Commands.PlusValidate, () => this.validate({ force: true })), - - registerCommand(Commands.PlusShowPlans, () => this.showPlans()), - - registerCommand(Commands.PlusHide, () => configuration.updateEffective('plusFeatures.enabled', false)), - registerCommand(Commands.PlusRestore, () => configuration.updateEffective('plusFeatures.enabled', true)), - - registerCommand('gitlens.plus.reset', () => this.logout(true)), - ]; - } - - async getAuthenticationSession(createIfNeeded: boolean = false): Promise { - return this.ensureSession(createIfNeeded); - } - - async getSubscription(cached = false): Promise { - const promise = this.ensureSession(false); - if (!cached) { - void (await promise); - } - return this._subscription; - } - - @debug() - async learnAboutPreviewOrTrial() { - const subscription = await this.getSubscription(); - if (subscription.state === SubscriptionState.FreeInPreviewTrial) { - void openWalkthrough( - this.container.context.extension.id, - 'gitlens.welcome', - 'gitlens.welcome.preview', - false, - ); - } else if (subscription.state === SubscriptionState.FreePlusInTrial) { - void openWalkthrough( - this.container.context.extension.id, - 'gitlens.welcome', - 'gitlens.welcome.trial', - false, - ); - } - } - - @log() - async loginOrSignUp(): Promise { - if (!(await ensurePlusFeaturesEnabled())) return false; - - // Abort any waiting authentication to ensure we can start a new flow - await this.container.accountAuthentication.abort(); - void this.showAccountView(); - - const session = await this.ensureSession(true); - const loggedIn = Boolean(session); - if (loggedIn) { - const { - account, - plan: { actual, effective }, - } = this._subscription; - - if (account?.verified === false) { - const confirm: MessageItem = { title: 'Resend Verification', isCloseAffordance: true }; - const cancel: MessageItem = { title: 'Cancel' }; - const result = await window.showInformationMessage( - `You must verify your email before you can access ${effective.name}.`, - confirm, - cancel, - ); - - if (result === confirm) { - void this.resendVerification(); - } - } else if (isSubscriptionTrial(this._subscription)) { - const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); - - const confirm: MessageItem = { title: 'OK', isCloseAffordance: true }; - const learn: MessageItem = { title: 'Learn More' }; - const result = await window.showInformationMessage( - `Welcome to ${ - effective.name - } (Trial). You can now try Pro features on privately hosted repos for ${pluralize( - 'more day', - remaining ?? 0, - )}.`, - { modal: true }, - confirm, - learn, - ); - - if (result === learn) { - void this.learnAboutPreviewOrTrial(); - } - } else if (isSubscriptionPaid(this._subscription)) { - void window.showInformationMessage( - `Welcome to ${actual.name}. You can now use Pro features on privately hosted repos.`, - 'OK', - ); - } else { - void window.showInformationMessage( - `Welcome to ${actual.name}. You can use Pro features on local & publicly hosted repos.`, - 'OK', - ); - } - } - return loggedIn; - } - - @log() - async logout(reset: boolean = false): Promise { - return this.logoutCore(reset); - } - - private async logoutCore(reset: boolean = false): Promise { - this._lastValidatedDate = undefined; - if (this._validationTimer != null) { - clearInterval(this._validationTimer); - this._validationTimer = undefined; - } - - await this.container.accountAuthentication.abort(); - - this._sessionPromise = undefined; - if (this._session != null) { - void this.container.accountAuthentication.removeSession(this._session.id); - this._session = undefined; - } else { - // Even if we don't have a session, make sure to remove any other matching sessions - void this.container.accountAuthentication.removeSessionsByScopes(authenticationProviderScopes); - } - - if (reset && this.container.debugging) { - this.changeSubscription(undefined); - - return; - } - - this.changeSubscription({ - ...this._subscription, - plan: { - actual: getSubscriptionPlan( - SubscriptionPlanId.Free, - false, - 0, - undefined, - this._subscription.plan?.actual?.startedOn != null - ? new Date(this._subscription.plan.actual.startedOn) - : undefined, - ), - effective: getSubscriptionPlan( - SubscriptionPlanId.Free, - false, - 0, - undefined, - this._subscription.plan?.effective?.startedOn != null - ? new Date(this._subscription.plan.actual.startedOn) - : undefined, - ), - }, - account: undefined, - }); - } - - @log() - manage(): void { - void env.openExternal(this.connection.getAccountsUri()); - } - - @log() - async purchase(): Promise { - if (!(await ensurePlusFeaturesEnabled())) return; - - if (this._subscription.account == null) { - this.showPlans(); - } else { - void env.openExternal(this.connection.getAccountsUri('subscription', 'product=gitlens&license=PRO')); - } - await this.showAccountView(); - } - - @gate() - @log() - async resendVerification(): Promise { - if (this._subscription.account?.verified) return true; - - const scope = getLogScope(); - - void this.showAccountView(true); - - const session = await this.ensureSession(false); - if (session == null) return false; - - try { - const rsp = await this.connection.fetchApi( - 'resend-email', - { - method: 'POST', - body: JSON.stringify({ id: session.account.id }), - }, - session.accessToken, - ); - - if (!rsp.ok) { - debugger; - Logger.error( - '', - scope, - `Unable to resend verification email; status=(${rsp.status}): ${rsp.statusText}`, - ); - - void window.showErrorMessage(`Unable to resend verification email; Status: ${rsp.statusText}`, 'OK'); - - return false; - } - - const confirm = { title: 'Recheck' }; - const cancel = { title: 'Cancel' }; - const result = await window.showInformationMessage( - "Once you have verified your email address, click 'Recheck'.", - confirm, - cancel, - ); - - if (result === confirm) { - await this.validate({ force: true }); - return true; - } - } catch (ex) { - Logger.error(ex, scope); - debugger; - - void window.showErrorMessage('Unable to resend verification email', 'OK'); - } - - return false; - } - - @log() - async showAccountView(silent: boolean = false): Promise { - if (silent && !configuration.get('plusFeatures.enabled', undefined, true)) return; - - if (!this.container.accountView.visible) { - await executeCommand(Commands.ShowAccountView); - } - } - - private showPlans(): void { - void env.openExternal(this.connection.getSiteUri('gitlens/pricing')); - } - - @gate() - @log() - async startPreviewTrial(silent?: boolean): Promise { - if (!(await ensurePlusFeaturesEnabled())) return; - - let { plan, previewTrial } = this._subscription; - if (previewTrial != null) { - void this.showAccountView(); - - if (!silent && plan.effective.id === SubscriptionPlanId.Free) { - const confirm: MessageItem = { title: 'Start Free GitKraken Trial', isCloseAffordance: true }; - const cancel: MessageItem = { title: 'Cancel' }; - const result = await window.showInformationMessage( - 'Your 3-day Pro preview has ended, start a free GitKraken trial to get an additional 7 days.\n\n✨ A trial or paid plan is required to use Pro features on privately hosted repos.', - { modal: true }, - confirm, - cancel, - ); - - if (result === confirm) { - void this.loginOrSignUp(); - } - } - - return; - } - - // Don't overwrite a trial that is already in progress - if (isSubscriptionInProTrial(this._subscription)) return; - - const startedOn = new Date(); - - let days: number; - let expiresOn = new Date(startedOn); - if (!this.container.debugging) { - // Normalize the date to just before midnight on the same day - expiresOn.setHours(23, 59, 59, 999); - expiresOn = createFromDateDelta(expiresOn, { days: 3 }); - days = 3; - } else { - expiresOn = createFromDateDelta(expiresOn, { minutes: 1 }); - days = 0; - } - - previewTrial = { - startedOn: startedOn.toISOString(), - expiresOn: expiresOn.toISOString(), - }; - - this.changeSubscription({ - ...this._subscription, - plan: { - ...this._subscription.plan, - effective: getSubscriptionPlan(SubscriptionPlanId.Pro, false, 0, undefined, startedOn, expiresOn), - }, - previewTrial: previewTrial, - }); - - if (!silent) { - setTimeout(async () => { - const confirm: MessageItem = { title: 'OK', isCloseAffordance: true }; - const learn: MessageItem = { title: 'Learn More' }; - const result = await window.showInformationMessage( - `You can now preview Pro features for ${pluralize( - 'day', - days, - )}. After which, you can start a free GitKraken trial for an additional 7 days.`, - confirm, - learn, - ); - - if (result === learn) { - void this.learnAboutPreviewOrTrial(); - } - }, 1); - } - } - - @gate() - @log() - async validate(options?: { force?: boolean }): Promise { - const scope = getLogScope(); - - const session = await this.ensureSession(false); - if (session == null) { - this.changeSubscription(this._subscription); - return; - } - - try { - await this.checkInAndValidate(session, options); - } catch (ex) { - Logger.error(ex, scope); - debugger; - } - } - - private _lastValidatedDate: Date | undefined; - @gate(s => s.account.id) - private async checkInAndValidate( - session: AuthenticationSession, - options?: { force?: boolean; showSlowProgress?: boolean }, - ): Promise { - // Only check in if we haven't in the last 12 hours - if ( - !options?.force && - this._lastValidatedDate != null && - Date.now() - this._lastValidatedDate.getTime() < 12 * 60 * 60 * 1000 && - !isSubscriptionExpired(this._subscription) - ) { - return; - } - - if (!options?.showSlowProgress) return this.checkInAndValidateCore(session); - - const validating = this.checkInAndValidateCore(session); - const result = await Promise.race([ - validating, - new Promise(resolve => setTimeout(resolve, 3000, true)), - ]); - - if (result) { - await window.withProgress( - { - location: ProgressLocation.Notification, - title: 'Validating your GitKraken account...', - }, - () => validating, - ); - } - } - - @debug({ args: { 0: s => s?.account.label } }) - private async checkInAndValidateCore(session: AuthenticationSession): Promise { - const scope = getLogScope(); - this._lastValidatedDate = undefined; - - try { - const checkInData = { - id: session.account.id, - platform: getPlatform(), - gitlensVersion: this.container.version, - machineId: env.machineId, - sessionId: env.sessionId, - vscodeEdition: env.appName, - vscodeHost: env.appHost, - vscodeVersion: codeVersion, - previewStartedOn: this._subscription.previewTrial?.startedOn, - previewExpiresOn: this._subscription.previewTrial?.expiresOn, - }; - - const rsp = await this.connection.fetchApi( - 'gitlens/checkin', - { - method: 'POST', - body: JSON.stringify(checkInData), - }, - session.accessToken, - ); - - if (!rsp.ok) { - throw new AccountValidationError('Unable to validate account', undefined, rsp.status, rsp.statusText); - } - - const data: GKCheckInResponse = await rsp.json(); - this.validateAndUpdateSubscription(data); - } catch (ex) { - Logger.error(ex, scope); - debugger; - if (ex instanceof AccountValidationError) throw ex; - - throw new AccountValidationError('Unable to validate account', ex); - } finally { - this.startDailyValidationTimer(); - } - } - - private startDailyValidationTimer(): void { - if (this._validationTimer != null) { - clearInterval(this._validationTimer); - } - - // Check 4 times a day to ensure we validate at least once a day - this._validationTimer = setInterval( - () => { - if (this._lastValidatedDate == null || this._lastValidatedDate.getDate() !== new Date().getDate()) { - void this.ensureSession(false, true); - } - }, - 6 * 60 * 60 * 1000, - ); - } - - @debug() - private validateAndUpdateSubscription(data: GKCheckInResponse) { - const subscription = getSubscriptionFromCheckIn(data); - - this._lastValidatedDate = new Date(); - this.changeSubscription( - { - ...this._subscription, - ...subscription, - }, - { store: true }, - ); - } - - private _sessionPromise: Promise | undefined; - private _session: AuthenticationSession | null | undefined; - - @gate() - @debug() - private async ensureSession(createIfNeeded: boolean, force?: boolean): Promise { - if (this._sessionPromise != null && this._session === undefined) { - void (await this._sessionPromise); - } - - if (!force && this._session != null) return this._session; - if (this._session === null && !createIfNeeded) return undefined; - - if (this._sessionPromise === undefined) { - this._sessionPromise = this.getOrCreateSession(createIfNeeded).then( - s => { - this._session = s; - this._sessionPromise = undefined; - return this._session; - }, - () => { - this._session = null; - this._sessionPromise = undefined; - return this._session; - }, - ); - } - - const session = await this._sessionPromise; - return session ?? undefined; - } - - @debug() - private async getOrCreateSession(createIfNeeded: boolean): Promise { - const scope = getLogScope(); - - let session: AuthenticationSession | null | undefined; - - try { - session = await authentication.getSession(authenticationProviderId, authenticationProviderScopes, { - createIfNone: createIfNeeded, - silent: !createIfNeeded, - }); - } catch (ex) { - session = null; - - if (ex instanceof Error && ex.message.includes('User did not consent')) { - Logger.debug(scope, 'User declined authentication'); - await this.logoutCore(); - return null; - } - - Logger.error(ex, scope); - } - - if (session == null) { - Logger.debug(scope, 'No valid session was found'); - await this.logoutCore(); - return session ?? null; - } - - try { - await this.checkInAndValidate(session, { showSlowProgress: createIfNeeded, force: createIfNeeded }); - } catch (ex) { - Logger.error(ex, scope); - debugger; - - this.container.telemetry.sendEvent('account/validation/failed', { - 'account.id': session.account.id, - exception: String(ex), - code: ex.original?.code, - statusCode: ex.statusCode, - }); - - Logger.debug(scope, `Account validation failed (${ex.statusCode ?? ex.original?.code})`); - - if (ex instanceof AccountValidationError) { - const name = session.account.label; - - // if ( - // (ex.statusCode != null && ex.statusCode < 500) || - // (ex.statusCode == null && (ex.original as any)?.code !== 'ENOTFOUND') - // ) { - if ( - (ex.original as any)?.code !== 'ENOTFOUND' && - ex.statusCode != null && - ex.statusCode < 500 && - ex.statusCode >= 400 - ) { - session = null; - await this.logoutCore(); - - if (createIfNeeded) { - const unauthorized = ex.statusCode === 401; - queueMicrotask(async () => { - const confirm: MessageItem = { title: 'Retry Sign In' }; - const result = await window.showErrorMessage( - `Unable to sign in to your (${name}) GitKraken account. Please try again. If this issue persists, please contact support.${ - unauthorized ? '' : ` Error=${ex.message}` - }`, - confirm, - ); - - if (result === confirm) { - void this.loginOrSignUp(); - } - }); - } - } else { - session = session ?? null; - - // if ((ex.original as any)?.code !== 'ENOTFOUND') { - // void window.showErrorMessage( - // `Unable to sign in to your (${name}) GitKraken account right now. Please try again in a few minutes. If this issue persists, please contact support. Error=${ex.message}`, - // 'OK', - // ); - // } - } - } - } - - return session; - } - - @debug() - private changeSubscription( - subscription: Optional | undefined, - options?: { silent?: boolean; store?: boolean }, - ): void { - if (subscription == null) { - subscription = { - plan: { - actual: getSubscriptionPlan(SubscriptionPlanId.Free, false, 0, undefined), - effective: getSubscriptionPlan(SubscriptionPlanId.Free, false, 0, undefined), - }, - account: undefined, - state: SubscriptionState.Free, - }; - } - - // If the effective plan has expired, then replace it with the actual plan - if (isSubscriptionExpired(subscription)) { - subscription = { - ...subscription, - plan: { - ...subscription.plan, - effective: subscription.plan.actual, - }, - }; - } - - // If we don't have a paid plan (or a non-preview trial), check if the preview trial has expired, if not apply it - if ( - !isSubscriptionPaid(subscription) && - subscription.previewTrial != null && - (getTimeRemaining(subscription.previewTrial.expiresOn) ?? 0) > 0 - ) { - subscription = { - ...subscription, - plan: { - ...subscription.plan, - effective: getSubscriptionPlan( - SubscriptionPlanId.Pro, - false, - 0, - undefined, - new Date(subscription.previewTrial.startedOn), - new Date(subscription.previewTrial.expiresOn), - ), - }, - }; - } - - subscription.state = computeSubscriptionState(subscription); - assertSubscriptionState(subscription); - - const previous = this._subscription as typeof this._subscription | undefined; // Can be undefined here, since we call this in the constructor - // Check the previous and new subscriptions are exactly the same - const matches = previous != null && JSON.stringify(previous) === JSON.stringify(subscription); - - // If the previous and new subscriptions are exactly the same, kick out - if (matches) { - if (options?.store) { - void this.storeSubscription(subscription); - } - return; - } - - queueMicrotask(() => { - let data = flattenSubscription(subscription); - this.container.telemetry.setGlobalAttributes(data); - - data = { - ...data, - ...(!matches ? flattenSubscription(previous, 'previous') : {}), - }; - - this.container.telemetry.sendEvent(previous == null ? 'subscription' : 'subscription/changed', data); - }); - - void this.storeSubscription(subscription); - - this._subscription = subscription; - this._etag = Date.now(); - - if (!options?.silent) { - this.updateContext(); - - if (previous != null) { - this._onDidChange.fire({ current: subscription, previous: previous, etag: this._etag }); - } - } - } - - private getStoredSubscription(): Subscription | undefined { - const storedSubscription = this.container.storage.get('premium:subscription'); - - let lastValidatedAt: number | undefined; - let subscription: Subscription | undefined; - if (storedSubscription?.data != null) { - ({ lastValidatedAt, ...subscription } = storedSubscription.data); - this._lastValidatedDate = lastValidatedAt != null ? new Date(lastValidatedAt) : undefined; - } else { - subscription = undefined; - } - - if (subscription != null) { - // Migrate the plan names to the latest names - (subscription.plan.actual as Mutable).name = getSubscriptionPlanName( - subscription.plan.actual.id, - ); - (subscription.plan.effective as Mutable).name = getSubscriptionPlanName( - subscription.plan.effective.id, - ); - } - - return subscription; - } - - private async storeSubscription(subscription: Subscription): Promise { - return this.container.storage.store('premium:subscription', { - v: 1, - data: { ...subscription, lastValidatedAt: this._lastValidatedDate?.getTime() }, - }); - } - - private _cancellationSource: CancellationTokenSource | undefined; - private _updateAccessContextDebounced: Deferrable | undefined; - - private updateContext(): void { - this._updateAccessContextDebounced?.cancel(); - if (this._updateAccessContextDebounced == null) { - this._updateAccessContextDebounced = debounce(this.updateAccessContext.bind(this), 500); - } - - if (this._cancellationSource != null) { - this._cancellationSource.cancel(); - } - this._cancellationSource = new CancellationTokenSource(); - - void this._updateAccessContextDebounced(this._cancellationSource.token); - this.updateStatusBar(); - - const { - plan: { actual }, - state, - } = this._subscription; - - void setContext('gitlens:plus', actual.id != SubscriptionPlanId.Free ? actual.id : undefined); - void setContext('gitlens:plus:state', state); - } - - private async updateAccessContext(cancellation: CancellationToken): Promise { - let allowed: boolean | 'mixed' = false; - // For performance reasons, only check if we have any repositories - if (this.container.git.repositoryCount !== 0) { - ({ allowed } = await this.container.git.access()); - if (cancellation.isCancellationRequested) return; - } - - const plusFeatures = configuration.get('plusFeatures.enabled') ?? true; - - let disallowedRepos: string[] | undefined; - - if (!plusFeatures && allowed === 'mixed') { - disallowedRepos = []; - for (const repo of this.container.git.repositories) { - if (repo.closed) continue; - - const access = await this.container.git.access(undefined, repo.uri); - if (cancellation.isCancellationRequested) return; - - if (!access.allowed) { - disallowedRepos.push(repo.uri.toString()); - } - } - } - - void setContext('gitlens:plus:enabled', Boolean(allowed) || plusFeatures); - void setContext('gitlens:plus:required', allowed === false); - void setContext('gitlens:plus:disallowedRepos', disallowedRepos); - } - - private updateStatusBar(): void { - const { - account, - plan: { effective }, - state, - } = this._subscription; - - if (effective.id === SubscriptionPlanId.Free) { - this._statusBarSubscription?.dispose(); - this._statusBarSubscription = undefined; - return; - } - - const trial = isSubscriptionTrial(this._subscription); - if (!trial && account?.verified !== false) { - this._statusBarSubscription?.dispose(); - this._statusBarSubscription = undefined; - return; - } - - if (this._statusBarSubscription == null) { - this._statusBarSubscription = window.createStatusBarItem( - 'gitlens.plus.subscription', - StatusBarAlignment.Left, - 1, - ); - } - - this._statusBarSubscription.name = 'GitKraken Subscription'; - this._statusBarSubscription.command = Commands.ShowAccountView; - - if (account?.verified === false) { - this._statusBarSubscription.text = `$(warning) ${effective.name} (Unverified)`; - this._statusBarSubscription.backgroundColor = new ThemeColor( - 'statusBarItem.warningBackground' satisfies CoreColors, - ); - this._statusBarSubscription.tooltip = new MarkdownString( - trial - ? `**Please verify your email**\n\nYou must verify your email before you can start your **${effective.name}** trial.\n\nClick for details` - : `**Please verify your email**\n\nYou must verify your email before you can use Pro features on privately hosted repos.\n\nClick for details`, - true, - ); - } else { - const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); - const isReactivatedTrial = - state === SubscriptionState.FreePlusInTrial && effective.trialReactivationCount > 0; - - this._statusBarSubscription.text = `${effective.name} (Trial)`; - this._statusBarSubscription.tooltip = new MarkdownString( - `${ - isReactivatedTrial - ? `[See what's new](https://help.gitkraken.com/gitlens/gitlens-release-notes-current/) with - ${pluralize('day', remaining ?? 0, { - infix: ' more ', - })} - in your **${effective.name}** trial.` - : `You have ${pluralize('day', remaining ?? 0)} remaining in your **${effective.name}** trial.` - } Once your trial ends, you'll need a paid plan to continue using ✨ features.\n\nTry our - [other developer tools](https://www.gitkraken.com/suite) also included in your trial.`, - true, - ); - } - - this._statusBarSubscription.show(); - } -} - -function flattenSubscription(subscription: Optional | undefined, prefix?: string) { - if (subscription == null) return {}; - - return { - ...flatten(subscription.account, { - arrays: 'join', - prefix: `${prefix ? `${prefix}.` : ''}account`, - skipPaths: ['name', 'email'], - skipNulls: true, - stringify: true, - }), - ...flatten(subscription.plan, { - prefix: `${prefix ? `${prefix}.` : ''}subscription`, - skipPaths: ['actual.name', 'effective.name'], - skipNulls: true, - stringify: true, - }), - ...flatten(subscription.previewTrial, { - prefix: `${prefix ? `${prefix}.` : ''}subscription.previewTrial`, - skipPaths: ['actual.name', 'effective.name'], - skipNulls: true, - stringify: true, - }), - 'subscription.state': subscription.state, - }; -} diff --git a/src/plus/webviews/account/accountWebview.ts b/src/plus/webviews/account/accountWebview.ts index 083061c..7fb3e2e 100644 --- a/src/plus/webviews/account/accountWebview.ts +++ b/src/plus/webviews/account/accountWebview.ts @@ -1,12 +1,12 @@ import { Disposable, window } from 'vscode'; import { getAvatarUriFromGravatarEmail } from '../../../avatars'; import type { Container } from '../../../container'; -import type { Subscription } from '../../gk/subscription/subscription'; import { registerCommand } from '../../../system/command'; import type { Deferrable } from '../../../system/function'; import { debounce } from '../../../system/function'; import type { WebviewController, WebviewProvider } from '../../../webviews/webviewController'; -import type { SubscriptionChangeEvent } from '../../gk/subscription/subscriptionService'; +import type { Subscription } from '../../gk/account/subscription'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; import type { State } from './protocol'; import { DidChangeSubscriptionNotificationType } from './protocol'; diff --git a/src/plus/webviews/account/protocol.ts b/src/plus/webviews/account/protocol.ts index fd09810..2328e74 100644 --- a/src/plus/webviews/account/protocol.ts +++ b/src/plus/webviews/account/protocol.ts @@ -1,6 +1,6 @@ import type { WebviewState } from '../../../webviews/protocol'; import { IpcNotificationType } from '../../../webviews/protocol'; -import type { Subscription } from '../../gk/subscription/subscription'; +import type { Subscription } from '../../gk/account/subscription'; export interface State extends WebviewState { webroot?: string; diff --git a/src/plus/webviews/focus/focusWebview.ts b/src/plus/webviews/focus/focusWebview.ts index 1926a12..a7066c0 100644 --- a/src/plus/webviews/focus/focusWebview.ts +++ b/src/plus/webviews/focus/focusWebview.ts @@ -34,7 +34,7 @@ import type { IpcMessage } from '../../../webviews/protocol'; import { onIpc } from '../../../webviews/protocol'; import type { WebviewController, WebviewProvider } from '../../../webviews/webviewController'; import type { EnrichedItem, FocusItem } from '../../focus/focusService'; -import type { SubscriptionChangeEvent } from '../../gk/subscription/subscriptionService'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; import type { ShowInCommitGraphCommandArgs } from '../graph/protocol'; import type { OpenBranchParams, diff --git a/src/plus/webviews/graph/graphWebview.ts b/src/plus/webviews/graph/graphWebview.ts index a235719..17b18b6 100644 --- a/src/plus/webviews/graph/graphWebview.ts +++ b/src/plus/webviews/graph/graphWebview.ts @@ -86,7 +86,7 @@ import { onIpc } from '../../../webviews/protocol'; import type { WebviewController, WebviewProvider } from '../../../webviews/webviewController'; import type { WebviewPanelShowCommandArgs } from '../../../webviews/webviewsController'; import { isSerializedState } from '../../../webviews/webviewsController'; -import type { SubscriptionChangeEvent } from '../../gk/subscription/subscriptionService'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; import type { BranchState, DimMergeCommitsParams, diff --git a/src/plus/webviews/graph/protocol.ts b/src/plus/webviews/graph/protocol.ts index f4f0df4..a5f6cd0 100644 --- a/src/plus/webviews/graph/protocol.ts +++ b/src/plus/webviews/graph/protocol.ts @@ -38,7 +38,7 @@ import type { DateTimeFormat } from '../../../system/date'; import type { WebviewItemContext, WebviewItemGroupContext } from '../../../system/webview'; import type { WebviewState } from '../../../webviews/protocol'; import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol'; -import type { Subscription } from '../../gk/subscription/subscription'; +import type { Subscription } from '../../gk/account/subscription'; export type { GraphRefType } from '@gitkraken/gitkraken-components'; diff --git a/src/plus/webviews/graph/statusbar.ts b/src/plus/webviews/graph/statusbar.ts index e68953b..622f993 100644 --- a/src/plus/webviews/graph/statusbar.ts +++ b/src/plus/webviews/graph/statusbar.ts @@ -5,7 +5,7 @@ import type { Container } from '../../../container'; import { configuration } from '../../../system/configuration'; import { getContext, onDidChangeContext } from '../../../system/context'; import { once } from '../../../system/function'; -import type { SubscriptionChangeEvent } from '../../gk/subscription/subscriptionService'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; import { arePlusFeaturesEnabled } from '../../gk/utils'; export class GraphStatusBarController implements Disposable { diff --git a/src/plus/webviews/timeline/timelineWebview.ts b/src/plus/webviews/timeline/timelineWebview.ts index 974719f..b087a5a 100644 --- a/src/plus/webviews/timeline/timelineWebview.ts +++ b/src/plus/webviews/timeline/timelineWebview.ts @@ -25,7 +25,7 @@ import type { WebviewController, WebviewProvider } from '../../../webviews/webvi import { updatePendingContext } from '../../../webviews/webviewController'; import type { WebviewShowOptions } from '../../../webviews/webviewsController'; import { isSerializedState } from '../../../webviews/webviewsController'; -import type { SubscriptionChangeEvent } from '../../gk/subscription/subscriptionService'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; import type { Commit, Period, State } from './protocol'; import { DidChangeNotificationType, OpenDataPointCommandType, UpdatePeriodCommandType } from './protocol'; diff --git a/src/plus/workspaces/workspacesService.ts b/src/plus/workspaces/workspacesService.ts index 90b6be6..84364a6 100644 --- a/src/plus/workspaces/workspacesService.ts +++ b/src/plus/workspaces/workspacesService.ts @@ -6,13 +6,13 @@ import type { GitRemote } from '../../git/models/remote'; import { RemoteResourceType } from '../../git/models/remoteResource'; import { Repository } from '../../git/models/repository'; import { showRepositoriesPicker } from '../../quickpicks/repositoryPicker'; -import { SubscriptionState } from '../gk/subscription/subscription'; import { log } from '../../system/decorators/log'; import { normalizePath } from '../../system/path'; import type { OpenWorkspaceLocation } from '../../system/utils'; import { openWorkspace } from '../../system/utils'; +import { SubscriptionState } from '../gk/account/subscription'; +import type { SubscriptionChangeEvent } from '../gk/account/subscriptionService'; import type { ServerConnection } from '../gk/serverConnection'; -import type { SubscriptionChangeEvent } from '../gk/subscription/subscriptionService'; import type { AddWorkspaceRepoDescriptor, CloudWorkspaceData, diff --git a/src/quickpicks/items/directive.ts b/src/quickpicks/items/directive.ts index 69d3747..96bb5c1 100644 --- a/src/quickpicks/items/directive.ts +++ b/src/quickpicks/items/directive.ts @@ -1,5 +1,5 @@ import type { QuickPickItem } from 'vscode'; -import type { Subscription } from '../../plus/gk/subscription/subscription'; +import type { Subscription } from '../../plus/gk/account/subscription'; export enum Directive { Back, diff --git a/src/uris/uriService.ts b/src/uris/uriService.ts index a4aec82..7cd864f 100644 --- a/src/uris/uriService.ts +++ b/src/uris/uriService.ts @@ -1,7 +1,7 @@ import type { Disposable, Event, Uri, UriHandler } from 'vscode'; import { EventEmitter, window } from 'vscode'; import type { Container } from '../container'; -import { AuthenticationUriPathPrefix } from '../plus/gk/authenticationConnection'; +import { AuthenticationUriPathPrefix } from '../plus/gk/account/authenticationConnection'; import { log } from '../system/decorators/log'; // This service is in charge of registering a URI handler and handling/emitting URI events received by GitLens. diff --git a/src/views/nodes/abstract/repositoriesSubscribeableNode.ts b/src/views/nodes/abstract/repositoriesSubscribeableNode.ts index 440ccb7..eaa9164 100644 --- a/src/views/nodes/abstract/repositoriesSubscribeableNode.ts +++ b/src/views/nodes/abstract/repositoriesSubscribeableNode.ts @@ -1,7 +1,7 @@ import { Disposable } from 'vscode'; import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; import { unknownGitUri } from '../../../git/gitUri'; -import type { SubscriptionChangeEvent } from '../../../plus/gk/subscription/subscriptionService'; +import type { SubscriptionChangeEvent } from '../../../plus/gk/account/subscriptionService'; import { debug } from '../../../system/decorators/log'; import { weakEvent } from '../../../system/event'; import { szudzikPairing } from '../../../system/function'; diff --git a/src/webviews/apps/plus/account/account.ts b/src/webviews/apps/plus/account/account.ts index 418a3e3..74fc3d9 100644 --- a/src/webviews/apps/plus/account/account.ts +++ b/src/webviews/apps/plus/account/account.ts @@ -1,7 +1,7 @@ /*global*/ import './account.scss'; import type { Disposable } from 'vscode'; -import { getSubscriptionTimeRemaining, SubscriptionState } from '../../../../plus/gk/subscription/subscription'; +import { getSubscriptionTimeRemaining, SubscriptionState } from '../../../../plus/gk/account/subscription'; import type { State } from '../../../../plus/webviews/account/protocol'; import { DidChangeSubscriptionNotificationType } from '../../../../plus/webviews/account/protocol'; import type { IpcMessage } from '../../../protocol'; diff --git a/src/webviews/apps/plus/account/components/account-content.ts b/src/webviews/apps/plus/account/components/account-content.ts index dd21d7d..e6f7430 100644 --- a/src/webviews/apps/plus/account/components/account-content.ts +++ b/src/webviews/apps/plus/account/components/account-content.ts @@ -1,6 +1,6 @@ import { css, html, LitElement, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { hasAccountFromSubscriptionState, SubscriptionState } from '../../../../../plus/gk/subscription/subscription'; +import { hasAccountFromSubscriptionState, SubscriptionState } from '../../../../../plus/gk/account/subscription'; import { pluralize } from '../../../../../system/string'; import { elementBase, linkBase } from '../../../shared/components/styles/lit/base.css'; import '../../../shared/components/button'; diff --git a/src/webviews/apps/plus/graph/GraphWrapper.tsx b/src/webviews/apps/plus/graph/GraphWrapper.tsx index 3677a09..67dbb89 100644 --- a/src/webviews/apps/plus/graph/GraphWrapper.tsx +++ b/src/webviews/apps/plus/graph/GraphWrapper.tsx @@ -18,7 +18,7 @@ import React, { createElement, useEffect, useMemo, useRef, useState } from 'reac import { getPlatform } from '@env/platform'; import type { DateStyle } from '../../../../config'; import type { SearchQuery } from '../../../../git/search'; -import type { Subscription } from '../../../../plus/gk/subscription/subscription'; +import type { Subscription } from '../../../../plus/gk/account/subscription'; import type { DidEnsureRowParams, DidSearchParams, diff --git a/src/webviews/apps/plus/shared/components/feature-gate-plus-state.ts b/src/webviews/apps/plus/shared/components/feature-gate-plus-state.ts index a102bfd..f8e1baf 100644 --- a/src/webviews/apps/plus/shared/components/feature-gate-plus-state.ts +++ b/src/webviews/apps/plus/shared/components/feature-gate-plus-state.ts @@ -1,6 +1,6 @@ import { css, html, LitElement, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { SubscriptionState } from '../../../../../plus/gk/subscription/subscription'; +import { SubscriptionState } from '../../../../../plus/gk/account/subscription'; import '../../../shared/components/button'; import { linkStyles } from './vscode.css'; diff --git a/src/webviews/apps/shared/components/feature-gate-badge.ts b/src/webviews/apps/shared/components/feature-gate-badge.ts index 6e158d3..d0e492d 100644 --- a/src/webviews/apps/shared/components/feature-gate-badge.ts +++ b/src/webviews/apps/shared/components/feature-gate-badge.ts @@ -1,13 +1,13 @@ import { css, html, LitElement, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import type { Subscription } from '../../../../plus/gk/subscription/subscription'; +import type { Subscription } from '../../../../plus/gk/account/subscription'; import { getSubscriptionStatePlanName, getSubscriptionTimeRemaining, isSubscriptionStatePaidOrTrial, isSubscriptionStateTrial, SubscriptionState, -} from '../../../../plus/gk/subscription/subscription'; +} from '../../../../plus/gk/account/subscription'; import '../../plus/shared/components/feature-gate-plus-state'; import { pluralize } from '../../../../system/string'; import { focusOutline } from './styles/lit/a11y.css'; diff --git a/src/webviews/apps/shared/components/feature-gate.ts b/src/webviews/apps/shared/components/feature-gate.ts index e9adea5..0c3b44f 100644 --- a/src/webviews/apps/shared/components/feature-gate.ts +++ b/src/webviews/apps/shared/components/feature-gate.ts @@ -1,6 +1,6 @@ import { css, html, LitElement, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { isSubscriptionStatePaidOrTrial, SubscriptionState } from '../../../../plus/gk/subscription/subscription'; +import { isSubscriptionStatePaidOrTrial, SubscriptionState } from '../../../../plus/gk/account/subscription'; import '../../plus/shared/components/feature-gate-plus-state'; @customElement('gk-feature-gate') diff --git a/src/webviews/apps/tsconfig.json b/src/webviews/apps/tsconfig.json index 1573ca3..4612ef0 100644 --- a/src/webviews/apps/tsconfig.json +++ b/src/webviews/apps/tsconfig.json @@ -11,7 +11,7 @@ }, "include": [ "**/*", - "../../plus/gk/subscription/**/*", + "../../plus/gk/**/*", "../../plus/webviews/**/*", "../../@types/**/*", "../protocol.ts", diff --git a/src/webviews/welcome/welcomeWebview.ts b/src/webviews/welcome/welcomeWebview.ts index 396564d..0cfa25b 100644 --- a/src/webviews/welcome/welcomeWebview.ts +++ b/src/webviews/welcome/welcomeWebview.ts @@ -1,9 +1,9 @@ import type { ConfigurationChangeEvent } from 'vscode'; import { Disposable, workspace } from 'vscode'; import type { Container } from '../../container'; -import type { SubscriptionChangeEvent } from '../../plus/gk/subscription/subscriptionService'; -import type { Subscription } from '../../plus/gk/subscription/subscription'; -import { SubscriptionState } from '../../plus/gk/subscription/subscription'; +import type { Subscription } from '../../plus/gk/account/subscription'; +import { SubscriptionState } from '../../plus/gk/account/subscription'; +import type { SubscriptionChangeEvent } from '../../plus/gk/account/subscriptionService'; import { configuration } from '../../system/configuration'; import type { IpcMessage } from '../protocol'; import { onIpc } from '../protocol';