Make onDidChangeSessions event for auth providers fire complete session
This commit is contained in:
parent
42edcdb41b
commit
9118a3461c
9 changed files with 77 additions and 50 deletions
|
@ -34,7 +34,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||||
|
|
||||||
const session = await loginService.login(scopeList.sort().join(' '));
|
const session = await loginService.login(scopeList.sort().join(' '));
|
||||||
Logger.info('Login success!');
|
Logger.info('Login success!');
|
||||||
onDidChangeSessions.fire({ added: [session.id], removed: [], changed: [] });
|
onDidChangeSessions.fire({ added: [session], removed: [], changed: [] });
|
||||||
return session;
|
return session;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If login was cancelled, do not notify user.
|
// If login was cancelled, do not notify user.
|
||||||
|
@ -63,8 +63,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||||
*/
|
*/
|
||||||
telemetryReporter.sendTelemetryEvent('logout');
|
telemetryReporter.sendTelemetryEvent('logout');
|
||||||
|
|
||||||
await loginService.logout(id);
|
const session = await loginService.logout(id);
|
||||||
onDidChangeSessions.fire({ added: [], removed: [id], changed: [] });
|
if (session) {
|
||||||
|
onDidChangeSessions.fire({ added: [], removed: [session], changed: [] });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
/* __GDPR__
|
/* __GDPR__
|
||||||
"logoutFailed" : { }
|
"logoutFailed" : { }
|
||||||
|
|
|
@ -74,8 +74,8 @@ export class GitHubAuthenticationProvider {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const added: string[] = [];
|
const added: vscode.AuthenticationSession[] = [];
|
||||||
const removed: string[] = [];
|
const removed: vscode.AuthenticationSession[] = [];
|
||||||
|
|
||||||
storedSessions.forEach(session => {
|
storedSessions.forEach(session => {
|
||||||
const matchesExisting = this._sessions.some(s => s.id === session.id);
|
const matchesExisting = this._sessions.some(s => s.id === session.id);
|
||||||
|
@ -83,7 +83,7 @@ export class GitHubAuthenticationProvider {
|
||||||
if (!matchesExisting) {
|
if (!matchesExisting) {
|
||||||
Logger.info('Adding session found in keychain');
|
Logger.info('Adding session found in keychain');
|
||||||
this._sessions.push(session);
|
this._sessions.push(session);
|
||||||
added.push(session.id);
|
added.push(session);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -97,7 +97,7 @@ export class GitHubAuthenticationProvider {
|
||||||
this._sessions.splice(sessionIndex, 1);
|
this._sessions.splice(sessionIndex, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
removed.push(session.id);
|
removed.push(session);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -185,15 +185,18 @@ export class GitHubAuthenticationProvider {
|
||||||
await this.storeSessions();
|
await this.storeSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async logout(id: string) {
|
public async logout(id: string): Promise<vscode.AuthenticationSession | undefined> {
|
||||||
Logger.info(`Logging out of ${id}`);
|
Logger.info(`Logging out of ${id}`);
|
||||||
const sessionIndex = this._sessions.findIndex(session => session.id === id);
|
const sessionIndex = this._sessions.findIndex(session => session.id === id);
|
||||||
|
let session: vscode.AuthenticationSession | undefined;
|
||||||
if (sessionIndex > -1) {
|
if (sessionIndex > -1) {
|
||||||
|
session = this._sessions[sessionIndex];
|
||||||
this._sessions.splice(sessionIndex, 1);
|
this._sessions.splice(sessionIndex, 1);
|
||||||
} else {
|
} else {
|
||||||
Logger.error('Session not found');
|
Logger.error('Session not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.storeSessions();
|
await this.storeSessions();
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -177,8 +177,8 @@ export class AzureActiveDirectoryService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkForUpdates(): Promise<void> {
|
private async checkForUpdates(): Promise<void> {
|
||||||
const addedIds: string[] = [];
|
const added: vscode.AuthenticationSession[] = [];
|
||||||
let removedIds: string[] = [];
|
let removed: vscode.AuthenticationSession[] = [];
|
||||||
const storedData = await this._keychain.getToken();
|
const storedData = await this._keychain.getToken();
|
||||||
if (storedData) {
|
if (storedData) {
|
||||||
try {
|
try {
|
||||||
|
@ -187,8 +187,8 @@ export class AzureActiveDirectoryService {
|
||||||
const matchesExisting = this._tokens.some(token => token.scope === session.scope && token.sessionId === session.id);
|
const matchesExisting = this._tokens.some(token => token.scope === session.scope && token.sessionId === session.id);
|
||||||
if (!matchesExisting && session.refreshToken) {
|
if (!matchesExisting && session.refreshToken) {
|
||||||
try {
|
try {
|
||||||
await this.refreshToken(session.refreshToken, session.scope, session.id);
|
const token = await this.refreshToken(session.refreshToken, session.scope, session.id);
|
||||||
addedIds.push(session.id);
|
added.push(this.convertToSessionSync(token));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.message === REFRESH_NETWORK_FAILURE) {
|
if (e.message === REFRESH_NETWORK_FAILURE) {
|
||||||
// Ignore, will automatically retry on next poll.
|
// Ignore, will automatically retry on next poll.
|
||||||
|
@ -203,7 +203,7 @@ export class AzureActiveDirectoryService {
|
||||||
const matchesExisting = sessions.some(session => token.scope === session.scope && token.sessionId === session.id);
|
const matchesExisting = sessions.some(session => token.scope === session.scope && token.sessionId === session.id);
|
||||||
if (!matchesExisting) {
|
if (!matchesExisting) {
|
||||||
await this.logout(token.sessionId);
|
await this.logout(token.sessionId);
|
||||||
removedIds.push(token.sessionId);
|
removed.push(this.convertToSessionSync(token));
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -211,13 +211,13 @@ export class AzureActiveDirectoryService {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error(e.message);
|
Logger.error(e.message);
|
||||||
// if data is improperly formatted, remove all of it and send change event
|
// if data is improperly formatted, remove all of it and send change event
|
||||||
removedIds = this._tokens.map(token => token.sessionId);
|
removed = this._tokens.map(this.convertToSessionSync);
|
||||||
this.clearSessions();
|
this.clearSessions();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (this._tokens.length) {
|
if (this._tokens.length) {
|
||||||
// Log out all, remove all local data
|
// Log out all, remove all local data
|
||||||
removedIds = this._tokens.map(token => token.sessionId);
|
removed = this._tokens.map(this.convertToSessionSync);
|
||||||
Logger.info('No stored keychain data, clearing local data');
|
Logger.info('No stored keychain data, clearing local data');
|
||||||
|
|
||||||
this._tokens = [];
|
this._tokens = [];
|
||||||
|
@ -230,11 +230,25 @@ export class AzureActiveDirectoryService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addedIds.length || removedIds.length) {
|
if (added.length || removed.length) {
|
||||||
onDidChangeSessions.fire({ added: addedIds, removed: removedIds, changed: [] });
|
onDidChangeSessions.fire({ added: added, removed: removed, changed: [] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a session object without checking for expiry and potentially refreshing.
|
||||||
|
* @param token The token information.
|
||||||
|
*/
|
||||||
|
private convertToSessionSync(token: IToken): MicrosoftAuthenticationSession {
|
||||||
|
return {
|
||||||
|
id: token.sessionId,
|
||||||
|
accessToken: token.accessToken!,
|
||||||
|
idToken: token.idToken,
|
||||||
|
account: token.account,
|
||||||
|
scopes: token.scope.split(' ')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async convertToSession(token: IToken): Promise<MicrosoftAuthenticationSession> {
|
private async convertToSession(token: IToken): Promise<MicrosoftAuthenticationSession> {
|
||||||
const resolvedTokens = await this.resolveAccessAndIdTokens(token);
|
const resolvedTokens = await this.resolveAccessAndIdTokens(token);
|
||||||
return {
|
return {
|
||||||
|
@ -478,8 +492,8 @@ export class AzureActiveDirectoryService {
|
||||||
if (token.expiresIn) {
|
if (token.expiresIn) {
|
||||||
this._refreshTimeouts.set(token.sessionId, setTimeout(async () => {
|
this._refreshTimeouts.set(token.sessionId, setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await this.refreshToken(token.refreshToken, scope, token.sessionId);
|
const refreshedToken = await this.refreshToken(token.refreshToken, scope, token.sessionId);
|
||||||
onDidChangeSessions.fire({ added: [], removed: [], changed: [token.sessionId] });
|
onDidChangeSessions.fire({ added: [], removed: [], changed: [this.convertToSessionSync(refreshedToken)] });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.message === REFRESH_NETWORK_FAILURE) {
|
if (e.message === REFRESH_NETWORK_FAILURE) {
|
||||||
const didSucceedOnRetry = await this.handleRefreshNetworkError(token.sessionId, token.refreshToken, scope);
|
const didSucceedOnRetry = await this.handleRefreshNetworkError(token.sessionId, token.refreshToken, scope);
|
||||||
|
@ -488,7 +502,7 @@ export class AzureActiveDirectoryService {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.logout(token.sessionId);
|
await this.logout(token.sessionId);
|
||||||
onDidChangeSessions.fire({ added: [], removed: [token.sessionId], changed: [] });
|
onDidChangeSessions.fire({ added: [], removed: [this.convertToSessionSync(token)], changed: [] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 1000 * (token.expiresIn - 30)));
|
}, 1000 * (token.expiresIn - 30)));
|
||||||
|
@ -613,13 +627,16 @@ export class AzureActiveDirectoryService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeInMemorySessionData(sessionId: string) {
|
private removeInMemorySessionData(sessionId: string): IToken | undefined {
|
||||||
const tokenIndex = this._tokens.findIndex(token => token.sessionId === sessionId);
|
const tokenIndex = this._tokens.findIndex(token => token.sessionId === sessionId);
|
||||||
|
let token: IToken | undefined;
|
||||||
if (tokenIndex > -1) {
|
if (tokenIndex > -1) {
|
||||||
|
token = this._tokens[tokenIndex];
|
||||||
this._tokens.splice(tokenIndex, 1);
|
this._tokens.splice(tokenIndex, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clearSessionTimeout(sessionId);
|
this.clearSessionTimeout(sessionId);
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
private pollForReconnect(sessionId: string, refreshToken: string, scope: string): void {
|
private pollForReconnect(sessionId: string, refreshToken: string, scope: string): void {
|
||||||
|
@ -645,7 +662,7 @@ export class AzureActiveDirectoryService {
|
||||||
const token = this._tokens.find(token => token.sessionId === sessionId);
|
const token = this._tokens.find(token => token.sessionId === sessionId);
|
||||||
if (token) {
|
if (token) {
|
||||||
token.accessToken = undefined;
|
token.accessToken = undefined;
|
||||||
onDidChangeSessions.fire({ added: [], removed: [], changed: [token.sessionId] });
|
onDidChangeSessions.fire({ added: [], removed: [], changed: [this.convertToSessionSync(token)] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -664,15 +681,21 @@ export class AzureActiveDirectoryService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async logout(sessionId: string) {
|
public async logout(sessionId: string): Promise<vscode.AuthenticationSession | undefined> {
|
||||||
Logger.info(`Logging out of session '${sessionId}'`);
|
Logger.info(`Logging out of session '${sessionId}'`);
|
||||||
this.removeInMemorySessionData(sessionId);
|
const token = this.removeInMemorySessionData(sessionId);
|
||||||
|
let session: vscode.AuthenticationSession | undefined;
|
||||||
|
if (token) {
|
||||||
|
session = this.convertToSessionSync(token);
|
||||||
|
}
|
||||||
|
|
||||||
if (this._tokens.length === 0) {
|
if (this._tokens.length === 0) {
|
||||||
await this._keychain.deleteToken();
|
await this._keychain.deleteToken();
|
||||||
} else {
|
} else {
|
||||||
this.storeTokenData();
|
this.storeTokenData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async clearSessions() {
|
public async clearSessions() {
|
||||||
|
|
|
@ -29,7 +29,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||||
telemetryReporter.sendTelemetryEvent('login');
|
telemetryReporter.sendTelemetryEvent('login');
|
||||||
|
|
||||||
const session = await loginService.login(scopes.sort().join(' '));
|
const session = await loginService.login(scopes.sort().join(' '));
|
||||||
onDidChangeSessions.fire({ added: [session.id], removed: [], changed: [] });
|
onDidChangeSessions.fire({ added: [session], removed: [], changed: [] });
|
||||||
return session;
|
return session;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
/* __GDPR__
|
/* __GDPR__
|
||||||
|
@ -47,8 +47,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||||
*/
|
*/
|
||||||
telemetryReporter.sendTelemetryEvent('logout');
|
telemetryReporter.sendTelemetryEvent('logout');
|
||||||
|
|
||||||
await loginService.logout(id);
|
const session = await loginService.logout(id);
|
||||||
onDidChangeSessions.fire({ added: [], removed: [id], changed: [] });
|
if (session) {
|
||||||
|
onDidChangeSessions.fire({ added: [], removed: [session], changed: [] });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
/* __GDPR__
|
/* __GDPR__
|
||||||
"logoutFailed" : { }
|
"logoutFailed" : { }
|
||||||
|
|
|
@ -1439,9 +1439,9 @@ export interface AuthenticationSession {
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export interface AuthenticationSessionsChangeEvent {
|
export interface AuthenticationSessionsChangeEvent {
|
||||||
added: ReadonlyArray<string>;
|
added: ReadonlyArray<AuthenticationSession>;
|
||||||
removed: ReadonlyArray<string>;
|
removed: ReadonlyArray<AuthenticationSession>;
|
||||||
changed: ReadonlyArray<string>;
|
changed: ReadonlyArray<AuthenticationSession>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
12
src/vs/vscode.proposed.d.ts
vendored
12
src/vs/vscode.proposed.d.ts
vendored
|
@ -38,19 +38,19 @@ declare module 'vscode' {
|
||||||
*/
|
*/
|
||||||
export interface AuthenticationProviderAuthenticationSessionsChangeEvent {
|
export interface AuthenticationProviderAuthenticationSessionsChangeEvent {
|
||||||
/**
|
/**
|
||||||
* The ids of the [AuthenticationSession](#AuthenticationSession)s that have been added.
|
* The [AuthenticationSession](#AuthenticationSession)s of the [AuthenticationProvider](#AuthentiationProvider) that have been added.
|
||||||
*/
|
*/
|
||||||
readonly added: ReadonlyArray<string>;
|
readonly added: ReadonlyArray<AuthenticationSession>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ids of the [AuthenticationSession](#AuthenticationSession)s that have been removed.
|
* The [AuthenticationSession](#AuthenticationSession)s of the [AuthenticationProvider](#AuthentiationProvider) that have been removed.
|
||||||
*/
|
*/
|
||||||
readonly removed: ReadonlyArray<string>;
|
readonly removed: ReadonlyArray<AuthenticationSession>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ids of the [AuthenticationSession](#AuthenticationSession)s that have been changed.
|
* The [AuthenticationSession](#AuthenticationSession)s of the [AuthenticationProvider](#AuthentiationProvider) that have been changed.
|
||||||
*/
|
*/
|
||||||
readonly changed: ReadonlyArray<string>;
|
readonly changed: ReadonlyArray<AuthenticationSession>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -122,10 +122,9 @@ export class MainThreadAuthenticationProvider extends Disposable {
|
||||||
|
|
||||||
async updateSessionItems(event: modes.AuthenticationSessionsChangeEvent): Promise<void> {
|
async updateSessionItems(event: modes.AuthenticationSessionsChangeEvent): Promise<void> {
|
||||||
const { added, removed } = event;
|
const { added, removed } = event;
|
||||||
const session = await this._proxy.$getSessions(this.id);
|
|
||||||
const addedSessions = session.filter(session => added.some(id => id === session.id));
|
|
||||||
|
|
||||||
removed.forEach(sessionId => {
|
removed.forEach(session => {
|
||||||
|
const sessionId = session.id;
|
||||||
const accountName = this._sessions.get(sessionId);
|
const accountName = this._sessions.get(sessionId);
|
||||||
if (accountName) {
|
if (accountName) {
|
||||||
this._sessions.delete(sessionId);
|
this._sessions.delete(sessionId);
|
||||||
|
@ -139,7 +138,7 @@ export class MainThreadAuthenticationProvider extends Disposable {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
addedSessions.forEach(session => this.registerSession(session));
|
added.forEach(session => this.registerSession(session));
|
||||||
}
|
}
|
||||||
|
|
||||||
login(scopes: string[]): Promise<modes.AuthenticationSession> {
|
login(scopes: string[]): Promise<modes.AuthenticationSession> {
|
||||||
|
|
|
@ -318,7 +318,7 @@ export class AuthenticationService extends Disposable implements IAuthentication
|
||||||
await provider.updateSessionItems(event);
|
await provider.updateSessionItems(event);
|
||||||
|
|
||||||
if (event.added) {
|
if (event.added) {
|
||||||
await this.updateNewSessionRequests(provider);
|
await this.updateNewSessionRequests(provider, event.added);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.removed) {
|
if (event.removed) {
|
||||||
|
@ -329,16 +329,14 @@ export class AuthenticationService extends Disposable implements IAuthentication
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateNewSessionRequests(provider: MainThreadAuthenticationProvider): Promise<void> {
|
private async updateNewSessionRequests(provider: MainThreadAuthenticationProvider, addedSessions: readonly AuthenticationSession[]): Promise<void> {
|
||||||
const existingRequestsForProvider = this._signInRequestItems.get(provider.id);
|
const existingRequestsForProvider = this._signInRequestItems.get(provider.id);
|
||||||
if (!existingRequestsForProvider) {
|
if (!existingRequestsForProvider) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessions = await provider.getSessions();
|
|
||||||
|
|
||||||
Object.keys(existingRequestsForProvider).forEach(requestedScopes => {
|
Object.keys(existingRequestsForProvider).forEach(requestedScopes => {
|
||||||
if (sessions.some(session => session.scopes.slice().sort().join('') === requestedScopes)) {
|
if (addedSessions.some(session => session.scopes.slice().sort().join('') === requestedScopes)) {
|
||||||
const sessionRequest = existingRequestsForProvider[requestedScopes];
|
const sessionRequest = existingRequestsForProvider[requestedScopes];
|
||||||
sessionRequest?.disposables.forEach(item => item.dispose());
|
sessionRequest?.disposables.forEach(item => item.dispose());
|
||||||
|
|
||||||
|
@ -352,12 +350,12 @@ export class AuthenticationService extends Disposable implements IAuthentication
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateAccessRequests(providerId: string, removedSessionIds: readonly string[]) {
|
private async updateAccessRequests(providerId: string, removedSessions: readonly AuthenticationSession[]) {
|
||||||
const providerRequests = this._sessionAccessRequestItems.get(providerId);
|
const providerRequests = this._sessionAccessRequestItems.get(providerId);
|
||||||
if (providerRequests) {
|
if (providerRequests) {
|
||||||
Object.keys(providerRequests).forEach(extensionId => {
|
Object.keys(providerRequests).forEach(extensionId => {
|
||||||
removedSessionIds.forEach(removedId => {
|
removedSessions.forEach(removed => {
|
||||||
const indexOfSession = providerRequests[extensionId].possibleSessions.findIndex(session => session.id === removedId);
|
const indexOfSession = providerRequests[extensionId].possibleSessions.findIndex(session => session.id === removed.id);
|
||||||
if (indexOfSession) {
|
if (indexOfSession) {
|
||||||
providerRequests[extensionId].possibleSessions.splice(indexOfSession, 1);
|
providerRequests[extensionId].possibleSessions.splice(indexOfSession, 1);
|
||||||
}
|
}
|
||||||
|
@ -530,7 +528,7 @@ export class AuthenticationService extends Disposable implements IAuthentication
|
||||||
if (session) {
|
if (session) {
|
||||||
addAccountUsage(this.storageService, providerId, session.account.label, extensionId, extensionName);
|
addAccountUsage(this.storageService, providerId, session.account.label, extensionId, extensionName);
|
||||||
const providerName = this.getLabel(providerId);
|
const providerName = this.getLabel(providerId);
|
||||||
this._onDidChangeSessions.fire({ providerId, label: providerName, event: { added: [], removed: [], changed: [session.id] } });
|
this._onDidChangeSessions.fire({ providerId, label: providerName, event: { added: [], removed: [], changed: [session] } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -581,7 +581,7 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat
|
||||||
}
|
}
|
||||||
|
|
||||||
private onDidChangeSessions(e: AuthenticationSessionsChangeEvent): void {
|
private onDidChangeSessions(e: AuthenticationSessionsChangeEvent): void {
|
||||||
if (this.currentSessionId && e.removed.includes(this.currentSessionId)) {
|
if (this.currentSessionId && e.removed.find(session => session.id === this.currentSessionId)) {
|
||||||
this.currentSessionId = undefined;
|
this.currentSessionId = undefined;
|
||||||
}
|
}
|
||||||
this.update();
|
this.update();
|
||||||
|
|
Loading…
Reference in a new issue