blob: d4d0ad178bda7856df7f2de5c9c6c7d1336d072e [file] [log] [blame]
/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '@polymer/iron-input/iron-input';
import '@polymer/paper-toggle-button/paper-toggle-button';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../gr-change-table-editor/gr-change-table-editor';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-diff-preferences/gr-diff-preferences';
import '../../shared/gr-page-nav/gr-page-nav';
import '../../shared/gr-select/gr-select';
import '../../shared/gr-icon/gr-icon';
import '../gr-account-info/gr-account-info';
import '../gr-agreements-list/gr-agreements-list';
import '../gr-edit-preferences/gr-edit-preferences';
import '../gr-email-editor/gr-email-editor';
import '../gr-gpg-editor/gr-gpg-editor';
import '../gr-group-list/gr-group-list';
import '../gr-http-password/gr-http-password';
import '../gr-identities/gr-identities';
import '../gr-menu-editor/gr-menu-editor';
import '../gr-preferences/gr-preferences';
import '../gr-ssh-editor/gr-ssh-editor';
import '../gr-watched-projects-editor/gr-watched-projects-editor';
import '../../shared/gr-dialog/gr-dialog';
import {GrAccountInfo} from '../gr-account-info/gr-account-info';
import {GrWatchedProjectsEditor} from '../gr-watched-projects-editor/gr-watched-projects-editor';
import {GrGroupList} from '../gr-group-list/gr-group-list';
import {GrIdentities} from '../gr-identities/gr-identities';
import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
import {
AccountDetailInfo,
PreferencesInput,
ServerInfo,
} from '../../../types/common';
import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
import {fire, fireAlert, fireTitleChange} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
import {LitElement, css, html} from 'lit';
import {customElement, query, queryAsync, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {paperStyles} from '../../../styles/gr-paper-styles';
import {fontStyles} from '../../../styles/gr-font-styles';
import {when} from 'lit/directives/when.js';
import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
import {grFormStyles} from '../../../styles/gr-form-styles';
import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
import {settingsViewModelToken} from '../../../models/views/settings';
import {
changeTablePrefs,
userModelToken,
} from '../../../models/user/user-model';
import {modalStyles} from '../../../styles/gr-modal-styles';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {rootUrl} from '../../../utils/url-util';
const HTTP_AUTH = ['HTTP', 'HTTP_LDAP'];
/**
* This provides an interface to show all settings for a user profile.
* In most cases a individual module is used per setting to make
* code more readable. In other cases, it is created within this module.
*/
@customElement('gr-settings-view')
export class GrSettingsView extends LitElement {
/**
* Fired with email confirmation text, or when the page reloads.
*
* @event show-alert
*/
@query('#accountInfo', true) accountInfo!: GrAccountInfo;
@query('#confirm-account-deletion')
private deleteAccountConfirmationDialog?: HTMLDialogElement;
@query('#dump-account-state')
private dumpAccountStateConfirmationDialog?: HTMLDialogElement;
@query('#watchedProjectsEditor', true)
watchedProjectsEditor!: GrWatchedProjectsEditor;
@query('#groupList', true) groupList!: GrGroupList;
@query('#identities', true) identities!: GrIdentities;
@query('#diffPrefs') diffPrefs!: GrDiffPreferences;
@queryAsync('#sshEditor') sshEditorPromise!: Promise<GrSshEditor>;
@queryAsync('#gpgEditor') gpgEditorPromise!: Promise<GrGpgEditor>;
@query('#emailEditor', true) emailEditor!: GrEmailEditor;
@state() prefs: PreferencesInput = {};
@state() private accountInfoChanged = false;
// private but used in test
@state() localChangeTableColumns: string[] = [];
@state() private loading = true;
@state() private changeTableChanged = false;
@state() private diffPrefsChanged = false;
@state() private watchedProjectsChanged = false;
@state() private keysChanged = false;
@state() private gpgKeysChanged = false;
// private but used in test
@state() newEmail?: string;
// private but used in test
@state() addingEmail = false;
// private but used in test
@state() lastSentVerificationEmail?: string | null = null;
// private but used in test
@state() serverConfig?: ServerInfo;
@state() private emailsChanged = false;
// private but used in test
@state() emailToken?: string;
// private but used in test
@state() showNumber?: boolean;
@state() account?: AccountDetailInfo;
@state() isDeletingAccount = false;
@state() accountState?: string;
// private but used in test
public _testOnly_loadingPromise?: Promise<void>;
private readonly restApiService = getAppContext().restApiService;
private readonly getUserModel = resolve(this, userModelToken);
// private but used in test
readonly flagsService = getAppContext().flagsService;
private readonly getViewModel = resolve(this, settingsViewModelToken);
private readonly getNavigation = resolve(this, navigationToken);
constructor() {
super();
subscribe(
this,
() => this.getViewModel().emailToken$,
x => {
this.emailToken = x;
this.confirmEmail();
}
);
subscribe(
this,
() => this.getUserModel().account$,
acc => {
this.account = acc;
}
);
subscribe(
this,
() => this.getUserModel().preferences$,
prefs => {
if (!prefs) {
throw new Error('getPreferences returned undefined');
}
this.prefs = prefs;
this.showNumber = !!prefs.legacycid_in_change_table;
this.localChangeTableColumns = changeTablePrefs(prefs);
}
);
}
// private, but used in tests
async confirmEmail() {
if (!this.emailToken) return;
const message = await this.restApiService.confirmEmail(this.emailToken);
if (message) fireAlert(this, message);
this.getViewModel().clearToken();
await this.getUserModel().loadEmails(true);
}
override connectedCallback() {
super.connectedCallback();
// Polymer 2: anchor tag won't work on shadow DOM
// we need to manually calling scrollIntoView when hash changed
document.addEventListener('location-change', this.handleLocationChange);
fireTitleChange('Settings');
}
private async getAccountState() {
const state = await this.restApiService.getAccountState();
if (state) {
this.accountState = JSON.stringify(state, null, 2);
} else {
this.accountState = 'ERROR: failed to get account state';
}
}
override firstUpdated() {
const promises: Array<Promise<unknown>> = [
this.accountInfo.loadData(),
this.watchedProjectsEditor.loadData(),
this.groupList.loadData(),
this.identities.loadData(),
];
promises.push(
this.restApiService.getConfig().then(config => {
this.serverConfig = config;
const configPromises: Array<Promise<void>> = [];
if (this.serverConfig?.sshd) {
configPromises.push(
this.sshEditorPromise.then(sshEditor => sshEditor.loadData())
);
}
if (this.serverConfig?.receive?.enable_signed_push) {
configPromises.push(
this.gpgEditorPromise.then(gpgEditor => gpgEditor.loadData())
);
}
return Promise.all(configPromises);
})
);
this._testOnly_loadingPromise = Promise.all(promises).then(() => {
this.loading = false;
// Handle anchor tag for initial load
this.handleLocationChange();
});
}
static override get styles() {
return [
sharedStyles,
paperStyles,
fontStyles,
grFormStyles,
modalStyles,
menuPageStyles,
pageNavStyles,
css`
:host {
color: var(--primary-text-color);
overflow: auto;
}
h2 {
font-family: var(--header-font-family);
font-size: var(--font-size-h2);
font-weight: var(--font-weight-h2);
line-height: var(--line-height-h2);
}
.newEmailInput {
width: 20em;
}
#email {
margin-bottom: var(--spacing-l);
}
.filters p {
margin-bottom: var(--spacing-l);
}
.queryExample em {
color: violet;
}
.toggle {
align-items: center;
display: flex;
margin-bottom: var(--spacing-l);
margin-right: var(--spacing-l);
}
.account-button {
margin-left: var(--spacing-l);
}
.account-state-output {
width: 100vh;
max-width: calc(100% - var(--spacing-xl));
height: 50vh;
margin-bottom: var(--spacing-l);
}
.account-state-note {
width: 100vh;
max-width: calc(100% - var(--spacing-xl));
}
.confirm-account-deletion-main ul {
list-style: disc inside;
margin-left: var(--spacing-l);
}
`,
];
}
override render() {
const isLoading = this.loading || this.loading === undefined;
return html`<div class="loading" ?hidden=${!isLoading}>Loading...</div>
<div ?hidden=${isLoading}>
<gr-page-nav class="navStyles">
<ul>
<li><a href="#Profile">Profile</a></li>
<li><a href="#Preferences">Preferences</a></li>
<li><a href="#DiffPreferences">Diff Preferences</a></li>
<li><a href="#EditPreferences">Edit Preferences</a></li>
<li><a href="#Menu">Menu</a></li>
<li><a href="#ChangeTableColumns">Change Table Columns</a></li>
<li><a href="#Notifications">Notifications</a></li>
<li><a href="#EmailAddresses">Email Addresses</a></li>
${when(
this.showHttpAuth(),
() =>
html`<li><a href="#HTTPCredentials">HTTP Credentials</a></li>`
)}
${when(
this.serverConfig?.sshd,
() => html`<li><a href="#SSHKeys"> SSH Keys </a></li>`
)}
${when(
this.serverConfig?.receive?.enable_signed_push,
() => html`<li><a href="#GPGKeys"> GPG Keys </a></li>`
)}
<li><a href="#Groups">Groups</a></li>
<li><a href="#Identities">Identities</a></li>
${when(
this.serverConfig?.auth.use_contributor_agreements,
() => html`<li><a href="#Agreements">Agreements</a></li>`
)}
<gr-endpoint-decorator name="settings-menu-item">
</gr-endpoint-decorator>
</ul>
</gr-page-nav>
<div class="main gr-form-styles">
<h1 class="heading-1">User Settings</h1>
<h2
id="Profile"
class=${this.computeHeaderClass(this.accountInfoChanged)}
>
Profile
</h2>
<fieldset id="profile">
<gr-account-info
id="accountInfo"
?hasUnsavedChanges=${this.accountInfoChanged}
@unsaved-changes-changed=${(e: ValueChangedEvent<boolean>) => {
this.accountInfoChanged = e.detail.value;
}}
@account-detail-update=${() => {
fire(this, 'account-detail-update', {});
}}
></gr-account-info>
<gr-button
@click=${() => {
this.accountInfo.save();
}}
?disabled=${!this.accountInfoChanged}
>Save changes</gr-button
>
<gr-button
class="account-button"
@click=${() => {
this.confirmDeleteAccount();
}}
>Delete Account</gr-button
>
<gr-button
class="account-button"
@click=${() => {
this.dumpAccountState();
}}
>Dump Account State</gr-button
>
<dialog id="confirm-account-deletion">
<gr-dialog
@cancel=${() => this.deleteAccountConfirmationDialog?.close()}
@confirm=${() => this.deleteAccount()}
.loading=${this.isDeletingAccount}
.loadingLabel=${'Deleting account'}
.confirmLabel=${'Delete account'}
>
<div class="confirm-account-deletion-header" slot="header">
Are you sure you wish to delete your account?
</div>
<div class="confirm-account-deletion-main" slot="main">
<ul>
<li>Deleting your account is not reversible.</li>
<li>Deleting your account will not delete your changes.</li>
</ul>
</div>
</gr-dialog>
</dialog>
<dialog id="dump-account-state">
<gr-dialog
cancel-label=""
@confirm=${() =>
this.dumpAccountStateConfirmationDialog?.close()}
confirm-label="OK"
confirm-on-enter=""
>
<div slot="header">Account State:</div>
<div slot="main">
<textarea class="account-state-output" readonly>
${this.accountState}</textarea
>
<p class="account-state-note">
Note: The account state may contain sensitive data (e.g.
deadnames). Share it with others only on a need to know
basis (e.g. for debugging account or permission issues).
</p>
</div>
</gr-dialog>
</dialog>
</fieldset>
<gr-preferences id="preferences"></gr-preferences>
<h2
id="DiffPreferences"
class=${this.computeHeaderClass(this.diffPrefsChanged)}
>
Diff Preferences
</h2>
<fieldset id="diffPreferences">
<gr-diff-preferences
id="diffPrefs"
@has-unsaved-changes-changed=${(
e: ValueChangedEvent<boolean>
) => {
this.diffPrefsChanged = e.detail.value;
}}
></gr-diff-preferences>
<gr-button
id="saveDiffPrefs"
@click=${() => {
this.diffPrefs.save();
}}
?disabled=${!this.diffPrefsChanged}
>Save changes</gr-button
>
</fieldset>
<gr-edit-preferences id="EditPreferences"></gr-edit-preferences>
<gr-menu-editor id="Menu"></gr-menu-editor>
<h2
id="ChangeTableColumns"
class=${this.computeHeaderClass(this.changeTableChanged)}
>
Change Table Columns
</h2>
<fieldset id="changeTableColumns">
<gr-change-table-editor
.showNumber=${this.showNumber}
@show-number-changed=${(e: ValueChangedEvent<boolean>) => {
this.showNumber = e.detail.value;
this.changeTableChanged = true;
}}
.displayedColumns=${this.localChangeTableColumns}
@displayed-columns-changed=${(e: ValueChangedEvent<string[]>) => {
this.localChangeTableColumns = e.detail.value;
this.changeTableChanged = true;
}}
>
</gr-change-table-editor>
<gr-button
id="saveChangeTable"
@click=${this.handleSaveChangeTable}
?disabled=${!this.changeTableChanged}
>Save changes</gr-button
>
</fieldset>
<h2
id="Notifications"
class=${this.computeHeaderClass(this.watchedProjectsChanged)}
>
Notifications
</h2>
<fieldset id="watchedProjects">
<gr-watched-projects-editor
@has-unsaved-changes-changed=${(
e: ValueChangedEvent<boolean>
) => {
this.watchedProjectsChanged = e.detail.value;
}}
id="watchedProjectsEditor"
></gr-watched-projects-editor>
<gr-button
@click=${() => {
this.watchedProjectsEditor.save();
}}
?disabled=${!this.watchedProjectsChanged}
id="_handleSaveWatchedProjects"
>Save changes</gr-button
>
</fieldset>
<h2
id="EmailAddresses"
class=${this.computeHeaderClass(this.emailsChanged)}
>
Email Addresses
</h2>
<fieldset id="email">
<gr-email-editor
id="emailEditor"
@has-unsaved-changes-changed=${(
e: ValueChangedEvent<boolean>
) => {
this.emailsChanged = e.detail.value;
}}
></gr-email-editor>
<gr-button
@click=${async () => {
await this.emailEditor.save();
}}
?disabled=${!this.emailsChanged}
>Save changes</gr-button
>
</fieldset>
<fieldset id="newEmail">
<section>
<span class="title">New email address</span>
<span class="value">
<iron-input
class="newEmailInput"
.bindValue=${this.newEmail}
@bind-value-changed=${(e: BindValueChangeEvent) => {
this.newEmail = e.detail.value;
}}
@keydown=${this.handleNewEmailKeydown}
>
<input
class="newEmailInput"
type="text"
?disabled=${this.addingEmail}
@keydown=${this.handleNewEmailKeydown}
placeholder="email@example.com"
/>
</iron-input>
</span>
</section>
<section
id="verificationSentMessage"
?hidden=${!this.lastSentVerificationEmail}
>
<p>
A verification email was sent to
<em>${this.lastSentVerificationEmail}</em>. Please check your
inbox.
</p>
</section>
<gr-button
?disabled=${!this.computeAddEmailButtonEnabled()}
@click=${this.handleAddEmailButton}
>Send verification</gr-button
>
</fieldset>
${when(
this.showHttpAuth(),
() => html` <div>
<h2 id="HTTPCredentials">HTTP Credentials</h2>
<fieldset>
<gr-http-password id="httpPass"></gr-http-password>
</fieldset>
</div>`
)}
${when(
this.serverConfig?.sshd,
() => html`<h2
id="SSHKeys"
class=${this.computeHeaderClass(this.keysChanged)}
>
SSH keys
</h2>
<gr-ssh-editor
id="sshEditor"
?hasUnsavedChanges=${this.keysChanged}
@has-unsaved-changes-changed=${(
e: ValueChangedEvent<boolean>
) => {
this.keysChanged = e.detail.value;
}}
></gr-ssh-editor>`
)}
${when(
this.serverConfig?.receive?.enable_signed_push,
() => html`<div>
<h2
id="GPGKeys"
class=${this.computeHeaderClass(this.gpgKeysChanged)}
>
GPG keys
</h2>
<gr-gpg-editor
id="gpgEditor"
?hasUnsavedChanges=${this.gpgKeysChanged}
@has-unsaved-changes-changed=${(
e: ValueChangedEvent<boolean>
) => {
this.gpgKeysChanged = e.detail.value;
}}
></gr-gpg-editor>
</div>`
)}
<h2 id="Groups">Groups</h2>
<fieldset>
<gr-group-list id="groupList"></gr-group-list>
</fieldset>
<h2 id="Identities">Identities</h2>
<fieldset>
<gr-identities
id="identities"
.serverConfig=${this.serverConfig}
></gr-identities>
</fieldset>
${when(
this.serverConfig?.auth.use_contributor_agreements,
() => html`<h2 id="Agreements">Agreements</h2>
<fieldset>
<gr-agreements-list id="agreementsList"></gr-agreements-list>
</fieldset>`
)}
<gr-endpoint-decorator name="settings-screen">
</gr-endpoint-decorator>
</div>
</div>`;
}
override disconnectedCallback() {
document.removeEventListener('location-change', this.handleLocationChange);
super.disconnectedCallback();
}
private readonly handleLocationChange = () => {
// Handle anchor tag after dom attached
const urlHash = window.location.hash;
if (urlHash) {
// Use shadowRoot for Polymer 2
const elem = (this.shadowRoot || document).querySelector(urlHash);
if (elem) {
setTimeout(() => elem.scrollIntoView(), 0);
}
}
};
reloadAccountDetail() {
Promise.all([this.accountInfo.loadData()]);
}
// private but used in test
async handleSaveChangeTable() {
this.prefs.change_table = this.localChangeTableColumns;
this.prefs.legacycid_in_change_table = this.showNumber;
await this.getUserModel().updatePreferences(this.prefs);
this.changeTableChanged = false;
}
private computeHeaderClass(changed?: boolean) {
return changed ? 'edited' : '';
}
// private but used in test
handleNewEmailKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.stopPropagation();
this.handleAddEmailButton();
}
}
// private but used in test
isNewEmailValid(newEmail?: string): newEmail is string {
return !!newEmail && newEmail.includes('@');
}
// private but used in test
computeAddEmailButtonEnabled() {
return this.isNewEmailValid(this.newEmail) && !this.addingEmail;
}
// private but used in test
handleAddEmailButton() {
if (!this.isNewEmailValid(this.newEmail)) return;
this.addingEmail = true;
this.restApiService.addAccountEmail(this.newEmail).then(async response => {
this.addingEmail = false;
// If it was unsuccessful.
if (response.status < 200 || response.status >= 300) {
return;
}
this.lastSentVerificationEmail = this.newEmail;
this.newEmail = '';
await this.getUserModel().loadEmails(true);
});
}
private confirmDeleteAccount() {
this.deleteAccountConfirmationDialog?.showModal();
}
private async deleteAccount() {
this.isDeletingAccount = true;
await this.accountInfo.delete();
this.isDeletingAccount = false;
this.deleteAccountConfirmationDialog?.close();
this.getNavigation().setUrl(rootUrl());
}
private async dumpAccountState() {
await this.getAccountState();
this.dumpAccountStateConfirmationDialog?.showModal();
}
// private but used in test
showHttpAuth() {
if (this.serverConfig?.auth?.git_basic_auth_policy) {
return HTTP_AUTH.includes(
this.serverConfig.auth.git_basic_auth_policy.toUpperCase()
);
}
return false;
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-settings-view': GrSettingsView;
}
}