[pve-devel] [PATCH widget-toolkit 5/7] add totp, wa and recovery creation and tfa edit windows
Wolfgang Bumiller
w.bumiller at proxmox.com
Tue Nov 9 12:27:19 CET 2021
plain copy from pbs with s/pbs/pmx/ and s/PBS/Proxmox/
Signed-off-by: Wolfgang Bumiller <w.bumiller at proxmox.com>
---
src/Makefile | 4 +
src/window/AddTfaRecovery.js | 224 ++++++++++++++++++++++++++
src/window/AddTotp.js | 297 +++++++++++++++++++++++++++++++++++
src/window/AddWebauthn.js | 226 ++++++++++++++++++++++++++
src/window/TfaEdit.js | 93 +++++++++++
5 files changed, 844 insertions(+)
create mode 100644 src/window/AddTfaRecovery.js
create mode 100644 src/window/AddTotp.js
create mode 100644 src/window/AddWebauthn.js
create mode 100644 src/window/TfaEdit.js
diff --git a/src/Makefile b/src/Makefile
index afb0cb2..ad7a3c2 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -79,6 +79,10 @@ JSSRC= \
window/AuthEditBase.js \
window/AuthEditOpenId.js \
window/TfaWindow.js \
+ window/AddTfaRecovery.js \
+ window/AddTotp.js \
+ window/AddWebauthn.js \
+ window/TfaEdit.js \
node/APT.js \
node/APTRepositories.js \
node/NetworkEdit.js \
diff --git a/src/window/AddTfaRecovery.js b/src/window/AddTfaRecovery.js
new file mode 100644
index 0000000..174d553
--- /dev/null
+++ b/src/window/AddTfaRecovery.js
@@ -0,0 +1,224 @@
+Ext.define('Proxmox.window.AddTfaRecovery', {
+ extend: 'Proxmox.window.Edit',
+ alias: 'widget.pmxAddTfaRecovery',
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ onlineHelp: 'user_mgmt',
+ isCreate: true,
+ isAdd: true,
+ subject: gettext('TFA recovery keys'),
+ width: 512,
+ method: 'POST',
+
+ fixedUser: false,
+
+ url: '/api2/extjs/access/tfa',
+ submitUrl: function(url, values) {
+ let userid = values.userid;
+ delete values.userid;
+ return `${url}/${userid}`;
+ },
+
+ apiCallDone: function(success, response) {
+ if (!success) {
+ return;
+ }
+
+ let values = response
+ .result
+ .data
+ .recovery
+ .map((v, i) => `${i}: ${v}`)
+ .join("\n");
+ Ext.create('Proxmox.window.TfaRecoveryShow', {
+ autoShow: true,
+ userid: this.getViewModel().get('userid'),
+ values,
+ });
+ },
+
+ viewModel: {
+ data: {
+ has_entry: false,
+ userid: null,
+ },
+ },
+
+ controller: {
+ xclass: 'Ext.app.ViewController',
+ hasEntry: async function(userid) {
+ let me = this;
+ let view = me.getView();
+
+ try {
+ await Proxmox.Async.api2({
+ url: `${view.url}/${userid}/recovery`,
+ method: 'GET',
+ });
+ return true;
+ } catch (_response) {
+ return false;
+ }
+ },
+
+ init: function(view) {
+ this.onUseridChange(null, Proxmox.UserName);
+ },
+
+ onUseridChange: async function(field, userid) {
+ let me = this;
+ let vm = me.getViewModel();
+
+ me.userid = userid;
+ vm.set('userid', userid);
+
+ let has_entry = await me.hasEntry(userid);
+ vm.set('has_entry', has_entry);
+ },
+ },
+
+ items: [
+ {
+ xtype: 'pmxDisplayEditField',
+ name: 'userid',
+ cbind: {
+ editable: (get) => !get('fixedUser'),
+ value: () => Proxmox.UserName,
+ },
+ fieldLabel: gettext('User'),
+ editConfig: {
+ xtype: 'pmxUserSelector',
+ allowBlank: false,
+ validator: function(_value) {
+ return !this.up('window').getViewModel().get('has_entry');
+ },
+ },
+ renderer: Ext.String.htmlEncode,
+ listeners: {
+ change: 'onUseridChange',
+ },
+ },
+ {
+ xtype: 'hiddenfield',
+ name: 'type',
+ value: 'recovery',
+ },
+ {
+ xtype: 'displayfield',
+ bind: {
+ hidden: '{!has_entry}',
+ },
+ hidden: true,
+ userCls: 'pmx-hint',
+ value: gettext('User already has recovery keys.'),
+ },
+ {
+ xtype: 'textfield',
+ name: 'password',
+ reference: 'password',
+ fieldLabel: gettext('Verify Password'),
+ inputType: 'password',
+ minLength: 5,
+ allowBlank: false,
+ validateBlank: true,
+ cbind: {
+ hidden: () => Proxmox.UserName === 'root at pam',
+ disabled: () => Proxmox.UserName === 'root at pam',
+ emptyText: () =>
+ Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
+ },
+ },
+ ],
+});
+
+Ext.define('Proxmox.window.TfaRecoveryShow', {
+ extend: 'Ext.window.Window',
+ alias: ['widget.pmxTfaRecoveryShow'],
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ width: 600,
+ modal: true,
+ resizable: false,
+ title: gettext('Recovery Keys'),
+ onEsc: Ext.emptyFn,
+
+ items: [
+ {
+ xtype: 'form',
+ layout: 'anchor',
+ bodyPadding: 10,
+ border: false,
+ fieldDefaults: {
+ anchor: '100%',
+ },
+ items: [
+ {
+ xtype: 'textarea',
+ editable: false,
+ inputId: 'token-secret-value',
+ cbind: {
+ value: '{values}',
+ },
+ fieldStyle: {
+ 'fontFamily': 'monospace',
+ },
+ height: '160px',
+ },
+ {
+ xtype: 'displayfield',
+ border: false,
+ padding: '5 0 0 0',
+ userCls: 'pmx-hint',
+ value: gettext('Please record recovery keys - they will only be displayed now'),
+ },
+ ],
+ },
+ ],
+ buttons: [
+ {
+ handler: function(b) {
+ document.getElementById('token-secret-value').select();
+ document.execCommand("copy");
+ },
+ iconCls: 'fa fa-clipboard',
+ text: gettext('Copy Recovery Keys'),
+ },
+ {
+ handler: function(b) {
+ let win = this.up('window');
+ win.paperkeys(win.values, win.userid);
+ },
+ iconCls: 'fa fa-print',
+ text: gettext('Print Recovery Keys'),
+ },
+ ],
+ paperkeys: function(keyString, userid) {
+ let me = this;
+
+ let printFrame = document.createElement("iframe");
+ Object.assign(printFrame.style, {
+ position: "fixed",
+ right: "0",
+ bottom: "0",
+ width: "0",
+ height: "0",
+ border: "0",
+ });
+ const host = document.location.host;
+ const title = document.title;
+ const html = `<html><head><script>
+ window.addEventListener('DOMContentLoaded', (ev) => window.print());
+ </script><style>@media print and (max-height: 150mm) {
+ h4, p { margin: 0; font-size: 1em; }
+ }</style></head><body style="padding: 5px;">
+ <h4>Recovery Keys for '${userid}' - ${title} (${host})</h4>
+<p style="font-size:1.5em;line-height:1.5em;font-family:monospace;
+ white-space:pre-wrap;overflow-wrap:break-word;">
+${keyString}
+</p>
+ </body></html>`;
+
+ printFrame.src = "data:text/html;base64," + btoa(html);
+ document.body.appendChild(printFrame);
+ },
+});
diff --git a/src/window/AddTotp.js b/src/window/AddTotp.js
new file mode 100644
index 0000000..3e0f5b5
--- /dev/null
+++ b/src/window/AddTotp.js
@@ -0,0 +1,297 @@
+/*global QRCode*/
+Ext.define('Proxmox.window.AddTotp', {
+ extend: 'Proxmox.window.Edit',
+ alias: 'widget.pmxAddTotp',
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ onlineHelp: 'user_mgmt',
+
+ modal: true,
+ resizable: false,
+ title: gettext('Add a TOTP login factor'),
+ width: 512,
+ layout: {
+ type: 'vbox',
+ align: 'stretch',
+ },
+
+ isAdd: true,
+ userid: undefined,
+ tfa_id: undefined,
+ issuerName: 'Proxmox',
+ fixedUser: false,
+
+ updateQrCode: function() {
+ let me = this;
+ let values = me.lookup('totp_form').getValues();
+ let algorithm = values.algorithm;
+ if (!algorithm) {
+ algorithm = 'SHA1';
+ }
+
+ let otpuri =
+ 'otpauth://totp/' +
+ encodeURIComponent(values.issuer) +
+ ':' +
+ encodeURIComponent(values.userid) +
+ '?secret=' + values.secret +
+ '&period=' + values.step +
+ '&digits=' + values.digits +
+ '&algorithm=' + algorithm +
+ '&issuer=' + encodeURIComponent(values.issuer);
+
+ me.getController().getViewModel().set('otpuri', otpuri);
+ me.qrcode.makeCode(otpuri);
+ me.lookup('challenge').setVisible(true);
+ me.down('#qrbox').setVisible(true);
+ },
+
+ viewModel: {
+ data: {
+ valid: false,
+ secret: '',
+ otpuri: '',
+ userid: null,
+ },
+
+ formulas: {
+ secretEmpty: function(get) {
+ return get('secret').length === 0;
+ },
+ },
+ },
+
+ controller: {
+ xclass: 'Ext.app.ViewController',
+ control: {
+ 'field[qrupdate=true]': {
+ change: function() {
+ this.getView().updateQrCode();
+ },
+ },
+ 'field': {
+ validitychange: function(field, valid) {
+ let me = this;
+ let viewModel = me.getViewModel();
+ let form = me.lookup('totp_form');
+ let challenge = me.lookup('challenge');
+ let password = me.lookup('password');
+ viewModel.set('valid', form.isValid() && challenge.isValid() && password.isValid());
+ },
+ },
+ '#': {
+ show: function() {
+ let me = this;
+ let view = me.getView();
+
+ view.qrdiv = document.createElement('div');
+ view.qrcode = new QRCode(view.qrdiv, {
+ width: 256,
+ height: 256,
+ correctLevel: QRCode.CorrectLevel.M,
+ });
+ view.down('#qrbox').getEl().appendChild(view.qrdiv);
+
+ view.getController().randomizeSecret();
+ },
+ },
+ },
+
+ randomizeSecret: function() {
+ let me = this;
+ let rnd = new Uint8Array(32);
+ window.crypto.getRandomValues(rnd);
+ let data = '';
+ rnd.forEach(function(b) {
+ // secret must be base32, so just use the first 5 bits
+ b = b & 0x1f;
+ if (b < 26) {
+ // A..Z
+ data += String.fromCharCode(b + 0x41);
+ } else {
+ // 2..7
+ data += String.fromCharCode(b-26 + 0x32);
+ }
+ });
+ me.getViewModel().set('secret', data);
+ },
+ },
+
+ items: [
+ {
+ xtype: 'form',
+ layout: 'anchor',
+ border: false,
+ reference: 'totp_form',
+ fieldDefaults: {
+ anchor: '100%',
+ },
+ items: [
+ {
+ xtype: 'pmxDisplayEditField',
+ name: 'userid',
+ cbind: {
+ editable: (get) => get('isAdd') && !get('fixedUser'),
+ value: () => Proxmox.UserName,
+ },
+ fieldLabel: gettext('User'),
+ editConfig: {
+ xtype: 'pmxUserSelector',
+ allowBlank: false,
+ },
+ renderer: Ext.String.htmlEncode,
+ listeners: {
+ change: function(field, newValue, oldValue) {
+ let vm = this.up('window').getViewModel();
+ vm.set('userid', newValue);
+ },
+ },
+ qrupdate: true,
+ },
+ {
+ xtype: 'textfield',
+ fieldLabel: gettext('Description'),
+ emptyText: gettext('For example: TFA device ID, required to identify multiple factors.'),
+ allowBlank: false,
+ name: 'description',
+ maxLength: 256,
+ },
+ {
+ layout: 'hbox',
+ border: false,
+ padding: '0 0 5 0',
+ items: [
+ {
+ xtype: 'textfield',
+ fieldLabel: gettext('Secret'),
+ emptyText: gettext('Unchanged'),
+ name: 'secret',
+ reference: 'tfa_secret',
+ regex: /^[A-Z2-7=]+$/,
+ regexText: 'Must be base32 [A-Z2-7=]',
+ maskRe: /[A-Z2-7=]/,
+ qrupdate: true,
+ bind: {
+ value: "{secret}",
+ },
+ flex: 4,
+ padding: '0 5 0 0',
+ },
+ {
+ xtype: 'button',
+ text: gettext('Randomize'),
+ reference: 'randomize_button',
+ handler: 'randomizeSecret',
+ flex: 1,
+ },
+ ],
+ },
+ {
+ xtype: 'numberfield',
+ fieldLabel: gettext('Time period'),
+ name: 'step',
+ // Google Authenticator ignores this and generates bogus data
+ hidden: true,
+ value: 30,
+ minValue: 10,
+ qrupdate: true,
+ },
+ {
+ xtype: 'numberfield',
+ fieldLabel: gettext('Digits'),
+ name: 'digits',
+ value: 6,
+ // Google Authenticator ignores this and generates bogus data
+ hidden: true,
+ minValue: 6,
+ maxValue: 8,
+ qrupdate: true,
+ },
+ {
+ xtype: 'textfield',
+ fieldLabel: gettext('Issuer Name'),
+ name: 'issuer',
+ cbind: {
+ value: '{issuerName}',
+ },
+ qrupdate: true,
+ },
+ {
+ xtype: 'box',
+ itemId: 'qrbox',
+ visible: false, // will be enabled when generating a qr code
+ bind: {
+ visible: '{!secretEmpty}',
+ },
+ style: {
+ 'background-color': 'white',
+ 'margin-left': 'auto',
+ 'margin-right': 'auto',
+ padding: '5px',
+ width: '266px',
+ height: '266px',
+ },
+ },
+ {
+ xtype: 'textfield',
+ fieldLabel: gettext('Verify Code'),
+ allowBlank: false,
+ reference: 'challenge',
+ name: 'challenge',
+ bind: {
+ disabled: '{!showTOTPVerifiction}',
+ visible: '{showTOTPVerifiction}',
+ },
+ emptyText: gettext('Scan QR code in a TOTP app and enter an auth. code here'),
+ },
+ {
+ xtype: 'textfield',
+ name: 'password',
+ reference: 'password',
+ fieldLabel: gettext('Verify Password'),
+ inputType: 'password',
+ minLength: 5,
+ allowBlank: false,
+ validateBlank: true,
+ cbind: {
+ hidden: () => Proxmox.UserName === 'root at pam',
+ disabled: () => Proxmox.UserName === 'root at pam',
+ emptyText: () =>
+ Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
+ },
+ },
+ ],
+ },
+ ],
+
+ initComponent: function() {
+ let me = this;
+ me.url = '/api2/extjs/access/tfa/';
+ me.method = 'POST';
+ me.callParent();
+ },
+
+ getValues: function(dirtyOnly) {
+ let me = this;
+ let viewmodel = me.getController().getViewModel();
+
+ let values = me.callParent(arguments);
+
+ let uid = encodeURIComponent(values.userid);
+ me.url = `/api2/extjs/access/tfa/${uid}`;
+ delete values.userid;
+
+ let data = {
+ description: values.description,
+ type: "totp",
+ totp: viewmodel.get('otpuri'),
+ value: values.challenge,
+ };
+
+ if (values.password) {
+ data.password = values.password;
+ }
+
+ return data;
+ },
+});
diff --git a/src/window/AddWebauthn.js b/src/window/AddWebauthn.js
new file mode 100644
index 0000000..f4a0b10
--- /dev/null
+++ b/src/window/AddWebauthn.js
@@ -0,0 +1,226 @@
+Ext.define('Proxmox.window.AddWebauthn', {
+ extend: 'Ext.window.Window',
+ alias: 'widget.pmxAddWebauthn',
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ onlineHelp: 'user_mgmt',
+
+ modal: true,
+ resizable: false,
+ title: gettext('Add a Webauthn login token'),
+ width: 512,
+
+ user: undefined,
+ fixedUser: false,
+
+ initComponent: function() {
+ let me = this;
+ me.callParent();
+ Ext.GlobalEvents.fireEvent('proxmoxShowHelp', me.onlineHelp);
+ },
+
+ viewModel: {
+ data: {
+ valid: false,
+ userid: null,
+ },
+ },
+
+ controller: {
+ xclass: 'Ext.app.ViewController',
+
+ control: {
+ 'field': {
+ validitychange: function(field, valid) {
+ let me = this;
+ let viewmodel = me.getViewModel();
+ let form = me.lookup('webauthn_form');
+ viewmodel.set('valid', form.isValid());
+ },
+ },
+ '#': {
+ show: function() {
+ let me = this;
+ let view = me.getView();
+
+ if (Proxmox.UserName === 'root at pam') {
+ view.lookup('password').setVisible(false);
+ view.lookup('password').setDisabled(true);
+ }
+ },
+ },
+ },
+
+ registerWebauthn: async function() {
+ let me = this;
+ let values = me.lookup('webauthn_form').getValues();
+ values.type = "webauthn";
+
+ let userid = values.user;
+ delete values.user;
+
+ me.getView().mask(gettext('Please wait...'), 'x-mask-loading');
+
+ try {
+ let register_response = await Proxmox.Async.api2({
+ url: `/api2/extjs/access/tfa/${userid}`,
+ method: 'POST',
+ params: values,
+ });
+
+ let data = register_response.result.data;
+ if (!data.challenge) {
+ throw "server did not respond with a challenge";
+ }
+
+ let creds = JSON.parse(data.challenge);
+
+ // Fix this up before passing it to the browser, but keep a copy of the original
+ // string to pass in the response:
+ let challenge_str = creds.publicKey.challenge;
+ creds.publicKey.challenge = Proxmox.Utils.base64url_to_bytes(challenge_str);
+ creds.publicKey.user.id =
+ Proxmox.Utils.base64url_to_bytes(creds.publicKey.user.id);
+
+ // convert existing authenticators structure
+ creds.publicKey.excludeCredentials =
+ (creds.publicKey.excludeCredentials || [])
+ .map((credential) => ({
+ id: Proxmox.Utils.base64url_to_bytes(credential.id),
+ type: credential.type,
+ }));
+
+ let msg = Ext.Msg.show({
+ title: `Webauthn: ${gettext('Setup')}`,
+ message: gettext('Please press the button on your Webauthn Device'),
+ buttons: [],
+ });
+
+ let token_response;
+ try {
+ token_response = await navigator.credentials.create(creds);
+ } catch (error) {
+ let errmsg = error.message;
+ if (error.name === 'InvalidStateError') {
+ errmsg = gettext('Is this token already registered?');
+ }
+ throw gettext('An error occurred during token registration.') +
+ `<br>${error.name}: ${errmsg}`;
+ }
+
+ // We cannot pass ArrayBuffers to the API, so extract & convert the data.
+ let response = {
+ id: token_response.id,
+ type: token_response.type,
+ rawId: Proxmox.Utils.bytes_to_base64url(token_response.rawId),
+ response: {
+ attestationObject: Proxmox.Utils.bytes_to_base64url(
+ token_response.response.attestationObject,
+ ),
+ clientDataJSON: Proxmox.Utils.bytes_to_base64url(
+ token_response.response.clientDataJSON,
+ ),
+ },
+ };
+
+ msg.close();
+
+ let params = {
+ type: "webauthn",
+ challenge: challenge_str,
+ value: JSON.stringify(response),
+ };
+
+ if (values.password) {
+ params.password = values.password;
+ }
+
+ await Proxmox.Async.api2({
+ url: `/api2/extjs/access/tfa/${userid}`,
+ method: 'POST',
+ params,
+ });
+ } catch (response) {
+ let error = response.result.message;
+ console.error(error); // for debugging if it's not displayable...
+ Ext.Msg.alert(gettext('Error'), error);
+ }
+
+ me.getView().close();
+ },
+ },
+
+ items: [
+ {
+ xtype: 'form',
+ reference: 'webauthn_form',
+ layout: 'anchor',
+ border: false,
+ bodyPadding: 10,
+ fieldDefaults: {
+ anchor: '100%',
+ },
+ items: [
+ {
+ xtype: 'pmxDisplayEditField',
+ name: 'user',
+ cbind: {
+ editable: (get) => !get('fixedUser'),
+ value: () => Proxmox.UserName,
+ },
+ fieldLabel: gettext('User'),
+ editConfig: {
+ xtype: 'pmxUserSelector',
+ allowBlank: false,
+ },
+ renderer: Ext.String.htmlEncode,
+ listeners: {
+ change: function(field, newValue, oldValue) {
+ let vm = this.up('window').getViewModel();
+ vm.set('userid', newValue);
+ },
+ },
+ },
+ {
+ xtype: 'textfield',
+ fieldLabel: gettext('Description'),
+ allowBlank: false,
+ name: 'description',
+ maxLength: 256,
+ emptyText: gettext('For example: TFA device ID, required to identify multiple factors.'),
+ },
+ {
+ xtype: 'textfield',
+ name: 'password',
+ reference: 'password',
+ fieldLabel: gettext('Verify Password'),
+ inputType: 'password',
+ minLength: 5,
+ allowBlank: false,
+ validateBlank: true,
+ cbind: {
+ hidden: () => Proxmox.UserName === 'root at pam',
+ disabled: () => Proxmox.UserName === 'root at pam',
+ emptyText: () =>
+ Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
+ },
+ },
+ ],
+ },
+ ],
+
+ buttons: [
+ {
+ xtype: 'proxmoxHelpButton',
+ },
+ '->',
+ {
+ xtype: 'button',
+ text: gettext('Register Webauthn Device'),
+ handler: 'registerWebauthn',
+ bind: {
+ disabled: '{!valid}',
+ },
+ },
+ ],
+});
diff --git a/src/window/TfaEdit.js b/src/window/TfaEdit.js
new file mode 100644
index 0000000..710f2b9
--- /dev/null
+++ b/src/window/TfaEdit.js
@@ -0,0 +1,93 @@
+Ext.define('Proxmox.window.TfaEdit', {
+ extend: 'Proxmox.window.Edit',
+ alias: 'widget.pmxTfaEdit',
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ onlineHelp: 'user_mgmt',
+
+ modal: true,
+ resizable: false,
+ title: gettext("Modify a TFA entry's description"),
+ width: 512,
+
+ layout: {
+ type: 'vbox',
+ align: 'stretch',
+ },
+
+ cbindData: function(initialConfig) {
+ let me = this;
+
+ let tfa_id = initialConfig['tfa-id'];
+ me.tfa_id = tfa_id;
+ me.defaultFocus = 'textfield[name=description]';
+ me.url = `/api2/extjs/access/tfa/${tfa_id}`;
+ me.method = 'PUT';
+ me.autoLoad = true;
+ return {};
+ },
+
+ initComponent: function() {
+ let me = this;
+ me.callParent();
+
+ if (Proxmox.UserName === 'root at pam') {
+ me.lookup('password').setVisible(false);
+ me.lookup('password').setDisabled(true);
+ }
+
+ let userid = me.tfa_id.split('/')[0];
+ me.lookup('userid').setValue(userid);
+ },
+
+ items: [
+ {
+ xtype: 'displayfield',
+ reference: 'userid',
+ editable: false,
+ fieldLabel: gettext('User'),
+ editConfig: {
+ xtype: 'pmxUserSelector',
+ allowBlank: false,
+ },
+ cbind: {
+ value: () => Proxmox.UserName,
+ },
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'description',
+ allowBlank: false,
+ fieldLabel: gettext('Description'),
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ fieldLabel: gettext('Enabled'),
+ name: 'enable',
+ uncheckedValue: 0,
+ defaultValue: 1,
+ checked: true,
+ },
+ {
+ xtype: 'textfield',
+ inputType: 'password',
+ fieldLabel: gettext('Password'),
+ minLength: 5,
+ reference: 'password',
+ name: 'password',
+ allowBlank: false,
+ validateBlank: true,
+ emptyText: gettext('verify current password'),
+ },
+ ],
+
+ getValues: function() {
+ var me = this;
+
+ var values = me.callParent(arguments);
+
+ delete values.userid;
+
+ return values;
+ },
+});
--
2.30.2
More information about the pve-devel
mailing list