Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,35 @@
"default": true,
"description": "When set it will load all apps in every app catalog from your tenant."
},
"spfx-toolkit.appRegistrations": {
"title": "Entra app registrations",
"type": "array",
"default": [],
"scope": "application",
"markdownDescription": "The Entra app registrations saved for signing in to Microsoft 365. Entries are added when you sign in and may be removed from the sign in list.",
"items": {
"type": "object",
"required": [
"clientId",
"tenantId"
],
"properties": {
"name": {
"type": "string",
"description": "The friendly name shown in the sign in list."
},
"clientId": {
"type": "string",
"description": "Application (client) ID"
},
"tenantId": {
"type": "string",
"description": "Tenant ID"
}
},
"additionalProperties": false
}
},
"spfx-toolkit.createNodeVersionFileDefaultValue": {
"title": "Default value for the Node version file option",
"type": "boolean",
Expand Down
3 changes: 3 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { IncreaseVersionActions } from './services/actions/IncreaseVersionAction
import { scheduleFeedbackChecks } from '@grconrad/vscode-extension-feedback';
import { getPackageManager, getInstallCommand } from './utils';
import { Logger } from './services/dataType/Logger';
import { AppRegistrations } from './services/dataType/AppRegistrations';


const feedbackFormUrl = 'https://forms.office.com/e/ZTfqAissqt';
Expand All @@ -36,6 +37,8 @@ export async function activate(context: vscode.ExtensionContext) {

AuthProvider.register(context);

void AppRegistrations.migrateLegacy(context);

Dependencies.registerCommands();
Scaffolder.registerCommands();
CliActions.registerCommands();
Expand Down
5 changes: 5 additions & 0 deletions src/models/AppRegistration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface AppRegistration {
name?: string;
clientId: string;
tenantId: string;
}
1 change: 1 addition & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './AppCatalogApp';
export * from './AppRegistration';
export * from './command-result';
export * from './CompatibilityItem';
export * from './GenerateWorkflowCommandInput';
Expand Down
5 changes: 4 additions & 1 deletion src/panels/CommandPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AuthProvider, M365AuthenticationSession } from '../providers/AuthProvid
import { CliActions } from '../services/actions/CliActions';
import { DebuggerCheck } from '../services/check/DebuggerCheck';
import { EnvironmentInformation } from '../services/dataType/EnvironmentInformation';
import { AppRegistrations } from '../services/dataType/AppRegistrations';
import { M365AgentsToolkitIntegration } from '../services/dataType/M365AgentsToolkitIntegration';
import { ProjectInformation } from '../services/dataType/ProjectInformation';
import { buildSPFxStatusBarTooltip } from '../services/check/SpfxStatusTooltip';
Expand Down Expand Up @@ -156,7 +157,9 @@ export class CommandPanel {
commands.executeCommand('setContext', ContextKeys.isLoggedIn, true);

accountCommands.push(new ActionTreeItem(session.account.label, '', { name: 'spo-m365', custom: true }, TreeItemCollapsibleState.Expanded, undefined, undefined, 'm365Account', []));
accountCommands[0].children?.push(new ActionTreeItem('Entra app registration', '', { name: 'entra-id', custom: true }, undefined, 'vscode.open', Uri.parse(`https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/${session.clientId}`), 'sp-admin-api-url'));

const appRegistrationName = AppRegistrations.getAll().find(registration => registration.clientId.toLowerCase() === session.clientId.toLowerCase())?.name ?? '';
accountCommands[0].children?.push(new ActionTreeItem('Entra app registration', appRegistrationName, { name: 'entra-id', custom: true }, undefined, 'vscode.open', Uri.parse(`https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/${session.clientId}`), 'sp-admin-api-url'));

const appCatalogUrls = await CliActions.appCatalogUrlsGet();
if (appCatalogUrls?.some) {
Expand Down
160 changes: 146 additions & 14 deletions src/providers/AuthProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EnvironmentInformation } from '../services/dataType/EnvironmentInformation';
import { env, authentication, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationSession, AuthenticationSessionAccountInformation, commands, Disposable, Event, EventEmitter, ProgressLocation, window, Progress } from 'vscode';
import { env, authentication, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationSession, AuthenticationSessionAccountInformation, commands, Disposable, Event, EventEmitter, ProgressLocation, window, Progress, QuickInputButton, QuickPickItem, QuickPickItemKind, ThemeIcon } from 'vscode';
import * as vscode from 'vscode';
import { Commands } from '../constants';
import { Logger } from '../services/dataType/Logger';
Expand All @@ -12,6 +12,16 @@ import { TerminalCommandExecuter } from '../services/executeWrappers/TerminalCom
import { isValidGUID } from '../utils/validateGuid';
import { CliExecuter } from '../services/executeWrappers/CliCommandExecuter';
import { EntraAppRegistration } from '../services/actions/EntraAppRegistration';
import { AppRegistrations } from '../services/dataType/AppRegistrations';
import { AppRegistration } from '../models';


const SIGN_IN_TITLE = 'SPFx Toolkit needs an Entra App Registration in order to grant the required permissions when signing in to your tenant. Pick one of the saved app registrations, provide the client ID and tenant ID of an existing one or create a new one.';

interface AppRegistrationQuickPickItem extends QuickPickItem {
registration?: AppRegistration;
action?: 'manual' | 'create';
}


export class M365AuthenticationSession implements AuthenticationSession {
Expand Down Expand Up @@ -76,23 +86,26 @@ export class AuthProvider implements AuthenticationProvider, Disposable {
}

public static async signIn(createIfNone: boolean = true) {
let shouldGenerateNewEntraAppReg = false;
if (!EnvironmentInformation.clientId) {
const shouldGenerateNewEntraAppRegistration = await window.showQuickPick(['Sign in using existing App Registration', 'Create a new App Registration'], {
title: 'SPFx Toolkit needs an Entra App Registration in order to grant the required permissions when signing in to your tenant. Do you want to provide client ID and tenant ID of an existing Entra App Registration or create a new one?',
ignoreFocusOut: true,
canPickMany: false
});
const selection = await AuthProvider.pickAppRegistration();

shouldGenerateNewEntraAppReg = shouldGenerateNewEntraAppRegistration === 'Create a new App Registration';
if (!selection) {
return;
}

if (shouldGenerateNewEntraAppReg) {
if (selection.action === 'create') {
EntraAppRegistration.showRegisterEntraAppRegistrationPage();
return;
}

EnvironmentInformation.clientId = await AuthProvider.context.globalState.get('clientId');
if (selection.registration) {
EnvironmentInformation.clientId = selection.registration.clientId;
EnvironmentInformation.tenantId = selection.registration.tenantId;
await AppRegistrations.setLastUsed(AuthProvider.context, selection.registration);

await authentication.getSession(AuthProvider.id, [], { createIfNone });
return;
}

const clientId = await window.showInputBox({
title: 'Specify the application (client) ID',
value: EnvironmentInformation.clientId ?? '',
Expand All @@ -117,7 +130,6 @@ export class AuthProvider implements AuthenticationProvider, Disposable {
throw new Error('Client ID is required');
}

EnvironmentInformation.tenantId = await AuthProvider.context.globalState.get('tenantId');
const tenantId = await window.showInputBox({
title: 'Specify the tenant ID',
value: EnvironmentInformation.tenantId ?? '',
Expand All @@ -143,14 +155,134 @@ export class AuthProvider implements AuthenticationProvider, Disposable {
throw new Error('Tenant ID is required');
}

const registration = await AuthProvider.nameAppRegistration({ clientId, tenantId });

EnvironmentInformation.clientId = clientId;
EnvironmentInformation.tenantId = tenantId;
await AuthProvider.context.globalState.update('clientId', clientId);
await AuthProvider.context.globalState.update('tenantId', tenantId);
await AppRegistrations.save(registration);
await AppRegistrations.setLastUsed(AuthProvider.context, registration);

await authentication.getSession(AuthProvider.id, [], { createIfNone });
}

/**
* Asks the user for an optional friendly name for the app registration.
* @param registration - The app registration to name.
* @returns The app registration including the name when one was provided.
*/
public static async nameAppRegistration(registration: AppRegistration): Promise<AppRegistration> {
const name = await window.showInputBox({
title: 'App registration name',
value: registration.name ?? '',
ignoreFocusOut: true,
placeHolder: 'For example: Contoso dev tenant',
prompt: 'Optionally provide a friendly name for your app registration. Leave empty to use the client ID instead.'
});

const trimmedName = name?.trim();
return trimmedName ? { ...registration, name: trimmedName } : { clientId: registration.clientId, tenantId: registration.tenantId };
}

/**
* Shows the list of saved app registrations together with the options to provide an existing one or to create a new one.
* Saved app registrations may be removed from the list using the button on the item.
* @returns The picked item or undefined when the user dismissed the list.
*/
private static async pickAppRegistration(): Promise<AppRegistrationQuickPickItem | undefined> {
const removeButton: QuickInputButton = {
iconPath: new ThemeIcon('trash'),
tooltip: 'Remove this app registration from the list'
};

const getItems = (): AppRegistrationQuickPickItem[] => {
const lastUsed = AppRegistrations.getLastUsed(AuthProvider.context);
const items: AppRegistrationQuickPickItem[] = AppRegistrations.getAll().map(registration => ({
label: registration.name || registration.clientId,
description: registration.name ? registration.clientId : undefined,
detail: `Tenant ID: ${registration.tenantId}${lastUsed?.clientId === registration.clientId ? ' (last used)' : ''}`,
buttons: [removeButton],
registration
}));

if (items.length > 0) {
items.push({ label: '', kind: QuickPickItemKind.Separator });
}

items.push({
label: 'Sign in using existing App Registration',
detail: 'Provide the client ID and tenant ID of an existing Entra app registration',
action: 'manual'
});
items.push({
label: 'Create a new App Registration',
detail: 'Let the SPFx Toolkit create a new Entra app registration in your tenant',
action: 'create'
});

return items;
};

const quickPick = window.createQuickPick<AppRegistrationQuickPickItem>();
quickPick.title = SIGN_IN_TITLE;
quickPick.ignoreFocusOut = true;
quickPick.canSelectMany = false;
quickPick.items = getItems();

const lastUsed = AppRegistrations.getLastUsed(AuthProvider.context);
const lastUsedItem = lastUsed ? quickPick.items.find(item => item.registration?.clientId === lastUsed.clientId) : undefined;
if (lastUsedItem) {
quickPick.activeItems = [lastUsedItem];
}

return new Promise<AppRegistrationQuickPickItem | undefined>((resolve) => {
let picked: AppRegistrationQuickPickItem | undefined = undefined;
// the confirmation dialog takes the focus away from the list, so the hide event must be ignored while it is shown
let isRemoving = false;

quickPick.onDidTriggerItemButton(async (event) => {
const registration = event.item.registration;
if (!registration) {
return;
}

isRemoving = true;
try {
const confirmation = await window.showWarningMessage(
`Are you sure you want to remove '${event.item.label}' from the list? The app registration itself will not be deleted from your tenant.`,
{ modal: true },
'Remove'
);

if (confirmation === 'Remove') {
await AppRegistrations.remove(registration);
quickPick.items = getItems();
}
} catch (error) {
Notifications.error(`Removing the app registration from the list failed: ${(error as Error).message}`);
} finally {
isRemoving = false;
quickPick.show();
}
});

quickPick.onDidAccept(() => {
picked = quickPick.selectedItems[0];
quickPick.hide();
});

quickPick.onDidHide(() => {
if (isRemoving) {
return;
}

quickPick.dispose();
resolve(picked);
});

quickPick.show();
});
}

/**
* Logs in the user.
* @param createIfNone - A boolean indicating whether to create a new session if none exists.
Expand Down
15 changes: 12 additions & 3 deletions src/services/actions/EntraAppRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { executeCommand } from '@pnp/cli-microsoft365-spfx-toolkit';
import { Logger } from '../dataType/Logger';
import { Notifications } from '../dataType/Notifications';
import { EnvironmentInformation } from '../dataType/EnvironmentInformation';
import { AppRegistrations } from '../dataType/AppRegistrations';
import { AuthProvider } from '../../providers/AuthProvider';


Expand Down Expand Up @@ -63,12 +64,20 @@ export class EntraAppRegistration {
}
});

await EntraAppRegistration.context.globalState.update('clientId', EnvironmentInformation.clientId);
await EntraAppRegistration.context.globalState.update('tenantId', EnvironmentInformation.tenantId);
const { clientId, tenantId } = EnvironmentInformation;
if (!clientId || !tenantId) {
PnPWebview.close();
AuthProvider.signIn();
return;
}

const registration = await AuthProvider.nameAppRegistration({ name: 'SPFx Toolkit', clientId, tenantId });
await AppRegistrations.save(registration);
await AppRegistrations.setLastUsed(EntraAppRegistration.context, registration);

Notifications.info('SPFx Toolkit App Registration created successfully');
PnPWebview.close();
AuthProvider.signIn();
AuthProvider.login();
});
});
}
Expand Down
Loading