Skip to content
Open
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
57 changes: 57 additions & 0 deletions src/atlclients/loginManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,4 +288,61 @@ export class LoginManager {
const data = await response.json();
return data.cloudId;
}

public async testCredentials(
site: SiteInfo,
credentials: BasicAuthInfo | PATAuthInfo,
): Promise<{ isOk: boolean; reason?: string }> {
const authHeader = this.authHeader(credentials);
// For cloud instances we can use the user ID as the credential ID (they're globally unique). Server instances
// will have a much smaller pool of user IDs so we use an arbitrary UUID as the credential ID.

try {
let siteDetailsUrl = '';
let apiUrl = '';
const protocol = site.protocol ? site.protocol : 'https:';
const contextPath = site.contextPath ? site.contextPath : '';
const transport = getAxiosInstance();
switch (site.product.key) {
case ProductJira.key:
siteDetailsUrl = `${protocol}//${site.host}${contextPath}/rest/api/2/myself`;
apiUrl = `${protocol}//${site.host}${contextPath}/rest`;
break;
case ProductBitbucket.key:
apiUrl = `${protocol}//${site.host}${contextPath}`;
// Needed when using a API key to login (credentials is PATAuthInfo):
const res = await transport(`${apiUrl}/rest/api/latest/build/capabilities`, {
method: 'GET',
headers: {
Authorization: authHeader,
},
...getAgent(site),
});
const slugRegex = /[\[\:\/\?#@\!\$&'\(\)\*\+,;\=%\\\[\]]/gi;
let ausername = res.headers['x-ausername'];
// convert the %40 and similar to special characters
ausername = decodeURIComponent(ausername);
// replace special characters with underscore (_)
ausername = ausername.replace(slugRegex, '_');
siteDetailsUrl = `${apiUrl}/rest/api/1.0/users/${ausername}`;
break;
}

const response = await transport(siteDetailsUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
...getAgent(site),
});
if (!response.status || response.status !== 200) {
return { isOk: false, reason: `HTTP error! status: ${response.status}` };
}
} catch (err) {
return { isOk: false, reason: err.message };
}

return { isOk: true };
}
}
7 changes: 6 additions & 1 deletion src/onboarding/quickFlow/authentication/authFlowUI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,11 @@ export class AuthFlowUI {
});
}

public inputPassword(state: PartialData, passwordName: string): Promise<UiResponse> {
public inputPassword(
state: PartialData,
passwordName: string,
preAcceptValidationFunc?: (value: string) => Promise<string | undefined>,
): Promise<UiResponse> {
const prompt =
state.authenticationType === AuthenticationType.ApiToken
? 'You can always visit [id.atlassian.com](https://id.atlassian.com/manage-profile/security/api-tokens) to generate a new token'
Expand All @@ -305,6 +309,7 @@ export class AuthFlowUI {
prompt,
value: state.password || '',
valueSelection: state.password ? [0, state.password.length] : undefined,
preAcceptValidationFunc,
});
}

Expand Down
37 changes: 36 additions & 1 deletion src/onboarding/quickFlow/authentication/states/common.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { BasicAuthInfo, PATAuthInfo, ProductJira } from 'src/atlclients/authInfo';
import { Container } from 'src/container';

import { UiAction } from '../../baseUI';
import { Transition } from '../../types';
import { AuthFlowUI } from '../authFlowUI';
Expand All @@ -6,6 +9,7 @@ import {
AuthenticationType,
AuthState,
PartialAuthData,
ServerCredentialType,
SpecialSiteOptions,
} from '../types';
import { ServerAuthStates } from './server';
Expand Down Expand Up @@ -149,7 +153,11 @@ export class CommonAuthStates {
}

const passwordType = data.authenticationType === AuthenticationType.ApiToken ? 'API Token' : 'Password';
const { value, action } = await ui.inputPassword(data, passwordType);

const { value, action } = await ui.inputPassword(data, passwordType, (password) =>
testCredentials(data, password),
);

if (action === UiAction.Back) {
return Transition.back();
}
Expand Down Expand Up @@ -189,3 +197,30 @@ export class CommonAuthStates {
},
};
}

const testCredentials = async (data: PartialAuthData, password: string) => {
const authInfo =
data.serverCredentialType === ServerCredentialType.PAT
? ({
token: password,
} as PATAuthInfo)
: ({
// Works for API token and server Basic Auth
username: data.username,
password: password,
} as BasicAuthInfo);

const { isOk, reason } = await Container.loginManager.testCredentials(
{
host: data.site!,
product: ProductJira,
contextPath: data.contextPath,
customSSLCertPaths: data.sslCertsPath,
pfxPath: data.pfxPath,
pfxPassphrase: data.pfxPassphrase,
},
authInfo,
);

return isOk ? undefined : `Failed to authenticate with ${data.site}: ${reason}`;
};
46 changes: 28 additions & 18 deletions src/onboarding/quickFlow/baseUI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type ExtraOptions = {
value?: string;
prompt?: string;
debounceValidationMs?: number;
preAcceptValidationFunc?: (value: string) => Promise<string | undefined>;
};

export class BaseUI {
Expand Down Expand Up @@ -64,24 +65,33 @@ export class BaseUI {
input.buttons = [QuickInputButtons.Back, ...(props.buttons || [])];

return new Promise((resolve, reject) => {
if (props.validateInput !== undefined) {
if (props.debounceValidationMs) {
const debouncer = new Debouncer(input, props.validateInput, props.debounceValidationMs);
input.onDidChangeValue(async (value) => {
if (props.validateInput !== undefined) {
const result = await debouncer.run(value);
input.validationMessage = result === null ? undefined : result;
}
});
} else {
input.onDidChangeValue(async (value) => {
const errorMessage = await props.validateInput?.(value);
input.validationMessage = errorMessage || undefined;
});
}
if (props.debounceValidationMs) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just removing the extra conditional here since it prevented validationMessage reset

const debouncer = new Debouncer(input, props.validateInput, props.debounceValidationMs);
input.onDidChangeValue(async (value) => {
if (props.validateInput !== undefined) {
const result = await debouncer.run(value);
input.validationMessage = result === null ? undefined : result;
} else {
input.validationMessage = undefined;
}
});
} else {
input.onDidChangeValue(async (value) => {
const errorMessage = await props.validateInput?.(value);
input.validationMessage = errorMessage || undefined;
});
}

input.onDidAccept(() => {
input.onDidAccept(async () => {
if (props.preAcceptValidationFunc) {
input.busy = true;
input.enabled = false;
const result = await props.preAcceptValidationFunc(input.value);
input.validationMessage = result;
input.busy = false;
input.enabled = true;
}

// According to the docs, `string` validation errors should in
// theory prevent accepting the form, but somehow they don't
if (!this.isInputValid(input)) {
Expand Down Expand Up @@ -221,7 +231,7 @@ class Debouncer<T> {

constructor(
private input: QuickInput,
private validator: (value: string) => T,
private validator?: (value: string) => T,
private delay: number = 1000,
) {}

Expand All @@ -238,7 +248,7 @@ class Debouncer<T> {
}
this.lastPromise = new Promise((resolve) => {
this.timeout = setTimeout(async () => {
resolve(await this.validator(value));
resolve(this.validator ? await this.validator(value) : undefined);
this.input.busy = false;
}, this.delay);
});
Expand Down
Loading