Add account context menu, #90385

This commit is contained in:
Rachel Macfarlane 2020-03-20 09:50:48 -07:00
parent aba05ec2b4
commit 2b86488f03
15 changed files with 439 additions and 76 deletions

View file

@ -349,6 +349,10 @@
{
"name": "vs/workbench/contrib/timeline",
"project": "vscode-workbench"
},
{
"name": "vs/workbench/services/authentication",
"project": "vscode-workbench"
}
]
}

View file

@ -8,7 +8,7 @@ import { keychain } from './common/keychain';
import { GitHubServer } from './githubServer';
import Logger from './common/logger';
export const onDidChangeSessions = new vscode.EventEmitter<void>();
export const onDidChangeSessions = new vscode.EventEmitter<vscode.AuthenticationSessionsChangeEvent>();
interface SessionData {
id: string;
@ -29,14 +29,16 @@ export class GitHubAuthenticationProvider {
private pollForChange() {
setTimeout(async () => {
const storedSessions = await this.readSessions();
let didChange = false;
const added: string[] = [];
const removed: string[] = [];
storedSessions.forEach(session => {
const matchesExisting = this._sessions.some(s => s.id === session.id);
// Another window added a session to the keychain, add it to our state as well
if (!matchesExisting) {
this._sessions.push(session);
didChange = true;
added.push(session.id);
}
});
@ -49,12 +51,12 @@ export class GitHubAuthenticationProvider {
this._sessions.splice(sessionIndex, 1);
}
didChange = true;
removed.push(session.id);
}
});
if (didChange) {
onDidChangeSessions.fire();
if (added.length || removed.length) {
onDidChangeSessions.fire({ added, removed, changed: [] });
}
this.pollForChange();

View file

@ -54,7 +54,7 @@ function parseQuery(uri: vscode.Uri) {
}, {});
}
export const onDidChangeSessions = new vscode.EventEmitter<void>();
export const onDidChangeSessions = new vscode.EventEmitter<vscode.AuthenticationSessionsChangeEvent>();
export const REFRESH_NETWORK_FAILURE = 'Network failure';
@ -129,7 +129,8 @@ export class AzureActiveDirectoryService {
private pollForChange() {
setTimeout(async () => {
let didChange = false;
const addedIds: string[] = [];
let removedIds: string[] = [];
const storedData = await keychain.getToken();
if (storedData) {
try {
@ -139,7 +140,7 @@ export class AzureActiveDirectoryService {
if (!matchesExisting) {
try {
await this.refreshToken(session.refreshToken, session.scope);
didChange = true;
addedIds.push(session.id);
} catch (e) {
if (e.message === REFRESH_NETWORK_FAILURE) {
// Ignore, will automatically retry on next poll.
@ -154,7 +155,7 @@ export class AzureActiveDirectoryService {
const matchesExisting = sessions.some(session => token.scope === session.scope && token.sessionId === session.id);
if (!matchesExisting) {
await this.logout(token.sessionId);
didChange = true;
removedIds.push(token.sessionId);
}
}));
@ -162,19 +163,19 @@ export class AzureActiveDirectoryService {
} catch (e) {
Logger.error(e.message);
// if data is improperly formatted, remove all of it and send change event
removedIds = this._tokens.map(token => token.sessionId);
this.clearSessions();
didChange = true;
}
} else {
if (this._tokens.length) {
// Log out all
removedIds = this._tokens.map(token => token.sessionId);
await this.clearSessions();
didChange = true;
}
}
if (didChange) {
onDidChangeSessions.fire();
if (addedIds.length || removedIds.length) {
onDidChangeSessions.fire({ added: addedIds, removed: removedIds, changed: [] });
}
this.pollForChange();
@ -377,7 +378,7 @@ export class AzureActiveDirectoryService {
this._refreshTimeouts.set(token.sessionId, setTimeout(async () => {
try {
await this.refreshToken(token.refreshToken, scope);
onDidChangeSessions.fire();
onDidChangeSessions.fire({ added: [], removed: [], changed: [token.sessionId] });
} catch (e) {
if (e.message === REFRESH_NETWORK_FAILURE) {
const didSucceedOnRetry = await this.handleRefreshNetworkError(token.sessionId, token.refreshToken, scope);
@ -386,7 +387,7 @@ export class AzureActiveDirectoryService {
}
} else {
await this.logout(token.sessionId);
onDidChangeSessions.fire();
onDidChangeSessions.fire({ added: [], removed: [token.sessionId], changed: [] });
}
}
}, 1000 * (parseInt(token.expiresIn) - 30)));
@ -548,9 +549,8 @@ export class AzureActiveDirectoryService {
const token = this._tokens.find(token => token.sessionId === sessionId);
if (token) {
token.accessToken = undefined;
onDidChangeSessions.fire({ added: [], removed: [], changed: [token.sessionId] });
}
onDidChangeSessions.fire();
}
const delayBeforeRetry = 5 * attempts * attempts;

View file

@ -25,13 +25,17 @@ export async function activate(context: vscode.ExtensionContext) {
login: async (scopes: string[]) => {
try {
await loginService.login(scopes.sort().join(' '));
const session = loginService.sessions[loginService.sessions.length - 1];
onDidChangeSessions.fire({ added: [session.id], removed: [], changed: [] });
return loginService.sessions[0]!;
} catch (e) {
throw e;
}
},
logout: async (id: string) => {
return loginService.logout(id);
await loginService.logout(id);
onDidChangeSessions.fire({ added: [], removed: [id], changed: [] });
vscode.window.showInformationMessage(localize('signedOut', "Successfully signed out."));
}
}));
@ -46,8 +50,9 @@ export async function activate(context: vscode.ExtensionContext) {
}
if (sessions.length === 1) {
await loginService.logout(loginService.sessions[0].id);
onDidChangeSessions.fire();
const id = loginService.sessions[0].id;
await loginService.logout(id);
onDidChangeSessions.fire({ added: [], removed: [id], changed: [] });
vscode.window.showInformationMessage(localize('signedOut', "Successfully signed out."));
return;
}
@ -61,7 +66,7 @@ export async function activate(context: vscode.ExtensionContext) {
if (selectedSession) {
await loginService.logout(selectedSession.id);
onDidChangeSessions.fire();
onDidChangeSessions.fire({ added: [], removed: [selectedSession.id], changed: [] });
vscode.window.showInformationMessage(localize('signedOut', "Successfully signed out."));
return;
}

View file

@ -1396,6 +1396,15 @@ export interface AuthenticationSession {
accountName: string;
}
/**
* @internal
*/
export interface AuthenticationSessionsChangeEvent {
added: string[];
removed: string[];
changed: string[];
}
export interface Command {
id: string;
title: string;

View file

@ -120,6 +120,7 @@ export class MenuId {
static readonly TimelineItemContext = new MenuId('TimelineItemContext');
static readonly TimelineTitle = new MenuId('TimelineTitle');
static readonly TimelineTitleContext = new MenuId('TimelineTitleContext');
static readonly AccountsContext = new MenuId('AccountsContext');
readonly id: number;
readonly _debugName: string;

View file

@ -40,6 +40,26 @@ declare module 'vscode' {
readonly removed: string[];
}
/**
* An [event](#Event) which fires when an [AuthenticationSession](#AuthenticationSession) is added, removed, or changed.
*/
export interface AuthenticationSessionsChangeEvent {
/**
* The ids of the [AuthenticationSession](#AuthenticationSession)s that have been added.
*/
readonly added: string[];
/**
* The ids of the [AuthenticationSession](#AuthenticationSession)s that have been removed.
*/
readonly removed: string[];
/**
* The ids of the [AuthenticationSession](#AuthenticationSession)s that have been changed.
*/
readonly changed: string[];
}
export interface AuthenticationProvider {
/**
* Used as an identifier for extensions trying to work with a particular
@ -53,7 +73,7 @@ declare module 'vscode' {
* An [event](#Event) which fires when the array of sessions has changed, or data
* within a session has changed.
*/
readonly onDidChangeSessions: Event<void>;
readonly onDidChangeSessions: Event<AuthenticationSessionsChangeEvent>;
/**
* Returns an array of current sessions.
@ -99,7 +119,7 @@ declare module 'vscode' {
* within a session has changed for a provider. Fires with the ids of the providers
* that have had session data change.
*/
export const onDidChangeSessions: Event<string[]>;
export const onDidChangeSessions: Event<{ [providerId: string]: AuthenticationSessionsChangeEvent }>;
}
//#endregion

View file

@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable } from 'vs/base/common/lifecycle';
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
import * as modes from 'vs/editor/common/modes';
import * as nls from 'vs/nls';
import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers';
@ -12,13 +12,141 @@ import { ExtHostAuthenticationShape, ExtHostContext, IExtHostContext, MainContex
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';
import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage';
import Severity from 'vs/base/common/severity';
import { MenuRegistry, MenuId } from 'vs/platform/actions/common/actions';
import { CommandsRegistry } from 'vs/platform/commands/common/commands';
import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput';
interface AuthDependent {
providerId: string;
label: string;
scopes: string[];
scopeDescriptions?: string;
}
const BUILT_IN_AUTH_DEPENDENTS: AuthDependent[] = [
{
providerId: 'microsoft',
label: 'Settings sync',
scopes: ['https://management.core.windows.net/.default', 'offline_access'],
scopeDescriptions: 'Read user email'
}
];
export class MainThreadAuthenticationProvider extends Disposable {
private _sessionMenuItems = new Map<string, IDisposable[]>();
private _sessionIds: string[] = [];
export class MainThreadAuthenticationProvider {
constructor(
private readonly _proxy: ExtHostAuthenticationShape,
public readonly id: string,
public readonly displayName: string
) { }
public readonly displayName: string,
public readonly dependents: AuthDependent[]
) {
super();
if (!dependents.length) {
return;
}
this.registerCommandsAndContextMenuItems();
}
private setPermissionsForAccount(quickInputService: IQuickInputService, doLogin?: boolean) {
const quickPick = quickInputService.createQuickPick();
quickPick.canSelectMany = true;
const items = this.dependents.map(dependent => {
return {
label: dependent.label,
description: dependent.scopeDescriptions,
picked: true,
scopes: dependent.scopes
};
});
quickPick.items = items;
// TODO read from storage and filter is not doLogin
quickPick.selectedItems = items;
quickPick.title = nls.localize('signInTo', "Sign in to {0}", this.displayName);
quickPick.placeholder = nls.localize('accountPermissions', "Choose what features and extensions to authorize to use this account");
quickPick.onDidAccept(() => {
const scopes = quickPick.selectedItems.reduce((previous, current) => previous.concat((current as any).scopes), []);
if (scopes.length && doLogin) {
this.login(scopes);
}
quickPick.dispose();
});
quickPick.onDidHide(() => {
quickPick.dispose();
});
quickPick.show();
}
private registerCommandsAndContextMenuItems(): void {
this._register(CommandsRegistry.registerCommand({
id: `signIn${this.id}`,
handler: (accessor, args) => {
this.setPermissionsForAccount(accessor.get(IQuickInputService), true);
},
}));
this._register(MenuRegistry.appendMenuItem(MenuId.AccountsContext, {
group: '2_providers',
command: {
id: `signIn${this.id}`,
title: nls.localize('addAccount', "Sign in to {0}", this.displayName)
},
order: 3
}));
this._proxy.$getSessions(this.id).then(sessions => {
sessions.forEach(session => this.registerSession(session));
});
}
private registerSession(session: modes.AuthenticationSession) {
this._sessionIds.push(session.id);
const menuItem = MenuRegistry.appendMenuItem(MenuId.AccountsContext, {
group: '1_accounts',
command: {
id: `configureSessions${session.id}`,
title: session.accountName
},
order: 3
});
const manageCommand = CommandsRegistry.registerCommand({
id: `configureSessions${session.id}`,
handler: (accessor, args) => {
const quickInputService = accessor.get(IQuickInputService);
const quickPick = quickInputService.createQuickPick();
const items = [{ label: 'Sign Out' }];
quickPick.items = items;
quickPick.onDidAccept(e => {
const selected = quickPick.selectedItems[0];
if (selected.label === 'Sign Out') {
this.logout(session.id);
}
quickPick.dispose();
});
quickPick.onDidHide(_ => {
quickPick.dispose();
});
quickPick.show();
},
});
this._sessionMenuItems.set(session.id, [menuItem, manageCommand]);
}
async getSessions(): Promise<ReadonlyArray<modes.AuthenticationSession>> {
return (await this._proxy.$getSessions(this.id)).map(session => {
@ -30,6 +158,24 @@ export class MainThreadAuthenticationProvider {
});
}
async updateSessionItems(): Promise<void> {
const currentSessions = await this._proxy.$getSessions(this.id);
const removedSessionIds = this._sessionIds.filter(id => !currentSessions.some(session => session.id === id));
const addedSessions = currentSessions.filter(session => !this._sessionIds.some(id => id === session.id));
removedSessionIds.forEach(id => {
const disposeables = this._sessionMenuItems.get(id);
if (disposeables) {
disposeables.forEach(disposeable => disposeable.dispose());
this._sessionMenuItems.delete(id);
}
});
addedSessions.forEach(session => this.registerSession(session));
this._sessionIds = currentSessions.map(session => session.id);
}
login(scopes: string[]): Promise<modes.AuthenticationSession> {
return this._proxy.$login(this.id, scopes).then(session => {
return {
@ -40,8 +186,14 @@ export class MainThreadAuthenticationProvider {
});
}
logout(accountId: string): Promise<void> {
return this._proxy.$logout(this.id, accountId);
logout(sessionId: string): Promise<void> {
return this._proxy.$logout(this.id, sessionId);
}
dispose(): void {
super.dispose();
this._sessionMenuItems.forEach(item => item.forEach(d => d.dispose()));
this._sessionMenuItems.clear();
}
}
@ -59,8 +211,10 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostAuthentication);
}
$registerAuthenticationProvider(id: string, displayName: string): void {
const provider = new MainThreadAuthenticationProvider(this._proxy, id, displayName);
async $registerAuthenticationProvider(id: string, displayName: string): Promise<void> {
const dependentBuiltIns = BUILT_IN_AUTH_DEPENDENTS.filter(dependency => dependency.providerId === id);
const provider = new MainThreadAuthenticationProvider(this._proxy, id, displayName, dependentBuiltIns);
this.authenticationService.registerAuthenticationProvider(id, provider);
}
@ -68,8 +222,8 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
this.authenticationService.unregisterAuthenticationProvider(id);
}
$onDidChangeSessions(id: string): void {
this.authenticationService.sessionsUpdate(id);
$onDidChangeSessions(id: string, event: modes.AuthenticationSessionsChangeEvent): void {
this.authenticationService.sessionsUpdate(id, event);
}
async $getSessionsPrompt(providerId: string, providerName: string, extensionId: string, extensionName: string): Promise<boolean> {

View file

@ -199,7 +199,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
login(providerId: string, scopes: string[]): Thenable<vscode.AuthenticationSession> {
return extHostAuthentication.login(extension, providerId, scopes);
},
get onDidChangeSessions(): Event<string[]> {
get onDidChangeSessions(): Event<{ [providerId: string]: vscode.AuthenticationSessionsChangeEvent }> {
return extHostAuthentication.onDidChangeSessions;
},
};

View file

@ -156,7 +156,7 @@ export interface MainThreadCommentsShape extends IDisposable {
export interface MainThreadAuthenticationShape extends IDisposable {
$registerAuthenticationProvider(id: string, displayName: string): void;
$unregisterAuthenticationProvider(id: string): void;
$onDidChangeSessions(id: string): void;
$onDidChangeSessions(providerId: string, event: modes.AuthenticationSessionsChangeEvent): void;
$getSessionsPrompt(providerId: string, providerName: string, extensionId: string, extensionName: string): Promise<boolean>;
$loginPrompt(providerId: string, providerName: string, extensionId: string, extensionName: string): Promise<boolean>;
}

View file

@ -17,8 +17,8 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape {
private _onDidChangeAuthenticationProviders = new Emitter<vscode.AuthenticationProvidersChangeEvent>();
readonly onDidChangeAuthenticationProviders: Event<vscode.AuthenticationProvidersChangeEvent> = this._onDidChangeAuthenticationProviders.event;
private _onDidChangeSessions = new Emitter<string[]>();
readonly onDidChangeSessions: Event<string[]> = this._onDidChangeSessions.event;
private _onDidChangeSessions = new Emitter<{ [providerId: string]: vscode.AuthenticationSessionsChangeEvent }>();
readonly onDidChangeSessions: Event<{ [providerId: string]: vscode.AuthenticationSessionsChangeEvent }> = this._onDidChangeSessions.event;
constructor(mainContext: IMainContext) {
this._proxy = mainContext.getProxy(MainContext.MainThreadAuthentication);
@ -85,9 +85,9 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape {
this._authenticationProviders.set(provider.id, provider);
const listener = provider.onDidChangeSessions(_ => {
this._proxy.$onDidChangeSessions(provider.id);
this._onDidChangeSessions.fire([provider.id]);
const listener = provider.onDidChangeSessions(e => {
this._proxy.$onDidChangeSessions(provider.id, e);
this._onDidChangeSessions.fire({ [provider.id]: e });
});
this._proxy.$registerAuthenticationProvider(provider.id, provider.displayName);

View file

@ -134,6 +134,61 @@ export class ToggleViewletAction extends Action {
}
}
export class AccountsActionViewItem extends ActivityActionViewItem {
constructor(
action: ActivityAction,
colors: (theme: IColorTheme) => ICompositeBarColors,
@IThemeService themeService: IThemeService,
@IContextMenuService protected contextMenuService: IContextMenuService,
@IMenuService protected menuService: IMenuService,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
) {
super(action, { draggable: false, colors, icon: true }, themeService);
}
render(container: HTMLElement): void {
super.render(container);
// Context menus are triggered on mouse down so that an item can be picked
// and executed with releasing the mouse over it
this._register(DOM.addDisposableListener(this.container, DOM.EventType.MOUSE_DOWN, (e: MouseEvent) => {
DOM.EventHelper.stop(e, true);
this.showContextMenu();
}));
this._register(DOM.addDisposableListener(this.container, DOM.EventType.KEY_UP, (e: KeyboardEvent) => {
let event = new StandardKeyboardEvent(e);
if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {
DOM.EventHelper.stop(e, true);
this.showContextMenu();
}
}));
this._register(DOM.addDisposableListener(this.container, TouchEventType.Tap, (e: GestureEvent) => {
DOM.EventHelper.stop(e, true);
this.showContextMenu();
}));
}
private showContextMenu(): void {
const accountsActions: IAction[] = [];
const accountsMenu = this.menuService.createMenu(MenuId.AccountsContext, this.contextKeyService);
const actionsDisposable = createAndFillInActionBarActions(accountsMenu, undefined, { primary: [], secondary: accountsActions });
const containerPosition = DOM.getDomNodePagePosition(this.container);
const location = { x: containerPosition.left + containerPosition.width / 2, y: containerPosition.top };
this.contextMenuService.showContextMenu({
getAnchor: () => location,
getActions: () => accountsActions,
onHide: () => {
accountsMenu.dispose();
dispose(actionsDisposable);
}
});
}
}
export class GlobalActivityActionViewItem extends ActivityActionViewItem {
constructor(

View file

@ -9,7 +9,7 @@ import { ActionsOrientation, ActionBar } from 'vs/base/browser/ui/actionbar/acti
import { GLOBAL_ACTIVITY_ID } from 'vs/workbench/common/activity';
import { Registry } from 'vs/platform/registry/common/platform';
import { Part } from 'vs/workbench/browser/part';
import { GlobalActivityActionViewItem, ViewletActivityAction, ToggleViewletAction, PlaceHolderToggleCompositePinnedAction, PlaceHolderViewletActivityAction } from 'vs/workbench/browser/parts/activitybar/activitybarActions';
import { GlobalActivityActionViewItem, ViewletActivityAction, ToggleViewletAction, PlaceHolderToggleCompositePinnedAction, PlaceHolderViewletActivityAction, AccountsActionViewItem } from 'vs/workbench/browser/parts/activitybar/activitybarActions';
import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet';
import { IBadge, NumberBadge } from 'vs/workbench/services/activity/common/activity';
import { IWorkbenchLayoutService, Parts, Position as SideBarPosition } from 'vs/workbench/services/layout/browser/layoutService';
@ -354,7 +354,17 @@ export class ActivitybarPart extends Part implements IActivityBarService {
private createGlobalActivityActionBar(container: HTMLElement): void {
this.globalActivityActionBar = this._register(new ActionBar(container, {
actionViewItemProvider: action => this.instantiationService.createInstance(GlobalActivityActionViewItem, action as ActivityAction, (theme: IColorTheme) => this.getActivitybarItemColors(theme)),
actionViewItemProvider: action => {
if (action.id === 'workbench.actions.manage') {
return this.instantiationService.createInstance(GlobalActivityActionViewItem, action as ActivityAction, (theme: IColorTheme) => this.getActivitybarItemColors(theme));
}
if (action.id === 'workbench.actions.accounts') {
return this.instantiationService.createInstance(AccountsActionViewItem, action as ActivityAction, (theme: IColorTheme) => this.getActivitybarItemColors(theme));
}
throw new Error(`No view item for action '${action.id}'`);
},
orientation: ActionsOrientation.VERTICAL,
ariaLabel: nls.localize('manage', "Manage"),
animated: false
@ -366,6 +376,13 @@ export class ActivitybarPart extends Part implements IActivityBarService {
cssClass: 'codicon-settings-gear'
});
const profileAction = new ActivityAction({
id: 'workbench.actions.accounts',
name: nls.localize('accounts', "Accounts"),
cssClass: 'codicon-account'
});
this.globalActivityActionBar.push(profileAction);
this.globalActivityActionBar.push(this.globalActivityAction);
}

View file

@ -15,7 +15,7 @@ import type { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { registerEditorContribution, ServicesAccessor } from 'vs/editor/browser/editorExtensions';
import type { IEditorContribution } from 'vs/editor/common/editorCommon';
import type { ITextModel } from 'vs/editor/common/model';
import { AuthenticationSession } from 'vs/editor/common/modes';
import { AuthenticationSession, AuthenticationSessionsChangeEvent } from 'vs/editor/common/modes';
import { IModelService } from 'vs/editor/common/services/modelService';
import { IModeService } from 'vs/editor/common/services/modeService';
import { ITextModelContentProvider, ITextModelService } from 'vs/editor/common/services/resolverService';
@ -63,6 +63,8 @@ const enum AuthStatus {
const CONTEXT_AUTH_TOKEN_STATE = new RawContextKey<string>('authTokenStatus', AuthStatus.Initializing);
const CONTEXT_CONFLICTS_SOURCES = new RawContextKey<string>('conflictsSources', '');
const USER_DATA_SYNC_ACCOUNT_PREFERENCE_KEY = 'userDataSyncAccountPreference';
type ConfigureSyncQuickPickItem = { id: SyncResource, label: string, description?: string };
function getSyncAreaLabel(source: SyncResource): string {
@ -195,18 +197,16 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo
return;
}
const selectedAccount = await this.quickInputService.pick(sessions.map(session => {
return {
id: session.id,
label: session.accountName
};
}), { canPickMany: false });
if (selectedAccount) {
const selected = sessions.filter(account => selectedAccount.id === account.id)[0];
this.logAuthenticatedEvent(selected);
await this.setActiveAccount(selected);
const accountPreference = this.storageService.get(USER_DATA_SYNC_ACCOUNT_PREFERENCE_KEY, StorageScope.GLOBAL);
if (accountPreference) {
const matchingSession = sessions.find(session => session.id === accountPreference);
if (matchingSession) {
this.setActiveAccount(matchingSession);
return;
}
}
await this.showSwitchAccountPicker(sessions);
}
private logAuthenticatedEvent(session: AuthenticationSession): void {
@ -246,15 +246,80 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo
this.updateBadge();
}
private async onDidChangeSessions(providerId: string): Promise<void> {
private async showSwitchAccountPicker(sessions: readonly AuthenticationSession[]): Promise<void> {
return new Promise((resolve, _) => {
const quickPick = this.quickInputService.createQuickPick<{ label: string, session: AuthenticationSession }>();
quickPick.title = localize('chooseAccountTitle', "Sync: Choose Account");
quickPick.placeholder = localize('chooseAccount', "Choose an account you would like to use for settings sync");
quickPick.items = sessions.map(session => {
return {
label: session.accountName,
session: session
};
});
quickPick.onDidHide(() => {
quickPick.dispose();
resolve();
});
quickPick.onDidAccept(() => {
const selected = quickPick.selectedItems[0];
this.setActiveAccount(selected.session);
this.storageService.store(USER_DATA_SYNC_ACCOUNT_PREFERENCE_KEY, selected.session.id, StorageScope.GLOBAL);
quickPick.dispose();
resolve();
});
quickPick.show();
});
}
private async onDidChangeSessions(e: { providerId: string, event: AuthenticationSessionsChangeEvent }): Promise<void> {
const { providerId, event } = e;
if (providerId === this.userDataSyncStore!.authenticationProviderId) {
if (this.activeAccount) {
// Try to update existing account, case where access token has been refreshed
const accounts = (await this.authenticationService.getSessions(this.userDataSyncStore!.authenticationProviderId) || []);
const matchingAccount = accounts.filter(a => a.id === this.activeAccount?.id)[0];
this.setActiveAccount(matchingAccount);
if (event.removed.length) {
const activeWasRemoved = !!event.removed.find(removed => removed === this.activeAccount!.id);
// If the current account was removed, check if another account can be used, otherwise offer to turn off sync
if (activeWasRemoved) {
const accounts = (await this.authenticationService.getSessions(this.userDataSyncStore!.authenticationProviderId) || []);
if (accounts.length) {
// Show switch dialog here
await this.showSwitchAccountPicker(accounts);
} else {
await this.turnOff();
this.setActiveAccount(undefined);
return;
}
}
}
if (event.added.length) {
// Offer to switch accounts
const accounts = (await this.authenticationService.getSessions(this.userDataSyncStore!.authenticationProviderId) || []);
await this.showSwitchAccountPicker(accounts);
return;
}
if (event.changed.length) {
const activeWasChanged = !!event.changed.find(changed => changed === this.activeAccount!.id);
if (activeWasChanged) {
// Try to update existing account, case where access token has been refreshed
const accounts = (await this.authenticationService.getSessions(this.userDataSyncStore!.authenticationProviderId) || []);
const matchingAccount = accounts.filter(a => a.id === this.activeAccount?.id)[0];
this.setActiveAccount(matchingAccount);
}
}
} else {
this.initializeActiveAccount();
await this.initializeActiveAccount();
// If logged in for the first time from accounts menu, prompt if sync should be turned on
if (this.activeAccount) {
this.turnOn(true);
}
}
}
}
@ -520,7 +585,7 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo
}
}
private async turnOn(): Promise<void> {
private async turnOn(skipAccountPick?: boolean): Promise<void> {
if (!this.storageService.getBoolean('sync.donotAskPreviewConfirmation', StorageScope.GLOBAL, false)) {
const result = await this.dialogService.show(
Severity.Info,
@ -562,7 +627,7 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo
disposables.add(Event.any(quickPick.onDidAccept, quickPick.onDidCustom)(async () => {
if (quickPick.selectedItems.length) {
this.updateConfiguration(items, quickPick.selectedItems);
this.doTurnOn().then(c, e);
this.doTurnOn(skipAccountPick).then(c, e);
quickPick.hide();
}
}));
@ -571,8 +636,8 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo
});
}
private async doTurnOn(): Promise<void> {
if (this.authenticationState.get() === AuthStatus.SignedIn) {
private async doTurnOn(skipAccountPick?: boolean): Promise<void> {
if (this.authenticationState.get() === AuthStatus.SignedIn && !skipAccountPick) {
await new Promise((c, e) => {
const disposables: DisposableStore = new DisposableStore();
const displayName = this.authenticationService.getDisplayName(this.userDataSyncStore!.authenticationProviderId);
@ -706,7 +771,7 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo
private async turnOff(): Promise<void> {
const result = await this.dialogService.confirm({
type: 'info',
message: localize('turn off sync confirmation', "Turn off Sync"),
message: localize('turn off sync confirmation', "Do you want to turn off sync?"),
detail: localize('turn off sync detail', "Your settings, keybindings, extensions and UI State will no longer be synced."),
primaryButton: localize('turn off', "Turn Off"),
checkbox: {

View file

@ -3,12 +3,14 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as nls from 'vs/nls';
import { Emitter, Event } from 'vs/base/common/event';
import { Disposable } from 'vs/base/common/lifecycle';
import { AuthenticationSession } from 'vs/editor/common/modes';
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
import { AuthenticationSession, AuthenticationSessionsChangeEvent } from 'vs/editor/common/modes';
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { MainThreadAuthenticationProvider } from 'vs/workbench/api/browser/mainThreadAuthentication';
import { MenuRegistry, MenuId } from 'vs/platform/actions/common/actions';
export const IAuthenticationService = createDecorator<IAuthenticationService>('IAuthenticationService');
@ -17,12 +19,12 @@ export interface IAuthenticationService {
registerAuthenticationProvider(id: string, provider: MainThreadAuthenticationProvider): void;
unregisterAuthenticationProvider(id: string): void;
sessionsUpdate(providerId: string): void;
sessionsUpdate(providerId: string, event: AuthenticationSessionsChangeEvent): void;
readonly onDidRegisterAuthenticationProvider: Event<string>;
readonly onDidUnregisterAuthenticationProvider: Event<string>;
readonly onDidChangeSessions: Event<string>;
readonly onDidChangeSessions: Event<{ providerId: string, event: AuthenticationSessionsChangeEvent }>;
getSessions(providerId: string): Promise<ReadonlyArray<AuthenticationSession> | undefined>;
getDisplayName(providerId: string): string;
login(providerId: string, scopes: string[]): Promise<AuthenticationSession>;
@ -31,6 +33,7 @@ export interface IAuthenticationService {
export class AuthenticationService extends Disposable implements IAuthenticationService {
_serviceBrand: undefined;
private _placeholderMenuItem: IDisposable | undefined;
private _authenticationProviders: Map<string, MainThreadAuthenticationProvider> = new Map<string, MainThreadAuthenticationProvider>();
@ -40,25 +43,53 @@ export class AuthenticationService extends Disposable implements IAuthentication
private _onDidUnregisterAuthenticationProvider: Emitter<string> = this._register(new Emitter<string>());
readonly onDidUnregisterAuthenticationProvider: Event<string> = this._onDidUnregisterAuthenticationProvider.event;
private _onDidChangeSessions: Emitter<string> = this._register(new Emitter<string>());
readonly onDidChangeSessions: Event<string> = this._onDidChangeSessions.event;
private _onDidChangeSessions: Emitter<{ providerId: string, event: AuthenticationSessionsChangeEvent }> = this._register(new Emitter<{ providerId: string, event: AuthenticationSessionsChangeEvent }>());
readonly onDidChangeSessions: Event<{ providerId: string, event: AuthenticationSessionsChangeEvent }> = this._onDidChangeSessions.event;
constructor() {
super();
this._placeholderMenuItem = MenuRegistry.appendMenuItem(MenuId.AccountsContext, {
command: {
id: 'noAuthenticationProviders',
title: nls.localize('noAuthenticationProviders', "No authentication providers registered")
},
});
}
registerAuthenticationProvider(id: string, authenticationProvider: MainThreadAuthenticationProvider): void {
this._authenticationProviders.set(id, authenticationProvider);
this._onDidRegisterAuthenticationProvider.fire(id);
if (authenticationProvider.dependents.length && this._placeholderMenuItem) {
this._placeholderMenuItem.dispose();
this._placeholderMenuItem = undefined;
}
}
unregisterAuthenticationProvider(id: string): void {
this._authenticationProviders.delete(id);
this._onDidUnregisterAuthenticationProvider.fire(id);
const provider = this._authenticationProviders.get(id);
if (provider) {
provider.dispose();
this._authenticationProviders.delete(id);
this._onDidUnregisterAuthenticationProvider.fire(id);
}
if (!this._authenticationProviders.size) {
this._placeholderMenuItem = MenuRegistry.appendMenuItem(MenuId.AccountsContext, {
command: {
id: 'noAuthenticationProviders',
title: nls.localize('noAuthenticationProviders', "No authentication providers registered")
},
});
}
}
sessionsUpdate(id: string): void {
this._onDidChangeSessions.fire(id);
sessionsUpdate(id: string, event: AuthenticationSessionsChangeEvent): void {
this._onDidChangeSessions.fire({ providerId: id, event: event });
const provider = this._authenticationProviders.get(id);
if (provider) {
provider.updateSessionItems();
}
}
getDisplayName(id: string): string {