From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 07FCE1FF0AB for ; Wed, 23 Sep 2026 23:01:17 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 20EE021746; Wed, 23 Sep 2026 23:00:16 +0200 (CEST) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH manager 7/9] ui: token edit: adapt to the datacenter API token policy Date: Wed, 23 Sep 2026 22:59:56 +0200 Message-ID: <20260923210000.4031318-8-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260923210000.4031318-1-t.lamprecht@proxmox.com> References: <20260923210000.4031318-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1790197207623 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.671 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: DHNQP3BDKR3I7ZTGOGHGNCC4THLLVWVU X-Message-ID-Hash: DHNQP3BDKR3I7ZTGOGHGNCC4THLLVWVU X-MailFrom: t.lamprecht@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Adapt the token dialogs up front when a datacenter token policy is set: require and pre-fill a compliant expiration date, bound the date picker, lock the expiration date when the policy forbids changing it, and lock privilege separation on. The policy is fetched anew right before a token dialog opens, so a policy changed elsewhere in the meantime is applied without needing a session reload; if that fetch fails the last known policy is used, the backend enforces it on the actual calls. As the date field is day-granular, it is no use for a sub-day maximum lifetime: at best a single future midnight fits the bound, covering only an arbitrary fraction of it, and on the 25 hour day of a DST transition even a one day maximum can lack one. In both cases the dialog switches to a seconds-granular lifetime field, resolved to an absolute expiration date only on submission to not lose validity time; on edit it stays empty to keep the current expiration date. On edit the date picker is only bounded if the current expiration date conforms, as tokens predating the policy would otherwise render the form permanently invalid and block edits the backend explicitly allows, since it only enforces settings that actually change. Signed-off-by: Thomas Lamprecht --- www/manager6/UIOptions.js | 14 ++++- www/manager6/dc/OptionView.js | 1 + www/manager6/dc/TokenEdit.js | 107 +++++++++++++++++++++++++++++++++- www/manager6/dc/TokenView.js | 36 +++++++----- 4 files changed, 141 insertions(+), 17 deletions(-) diff --git a/www/manager6/UIOptions.js b/www/manager6/UIOptions.js index 8c4674af1..8cd951963 100644 --- a/www/manager6/UIOptions.js +++ b/www/manager6/UIOptions.js @@ -10,7 +10,7 @@ Ext.define('PVE.UIOptions', { url: '/cluster/options', method: 'GET', success: function (response) { - for (const option of ['allowed-tags', 'console', 'tag-style']) { + for (const option of ['allowed-tags', 'console', 'tag-style', 'token-policy']) { PVE.UIOptions.options[option] = response?.result?.data?.[option]; } @@ -21,6 +21,18 @@ Ext.define('PVE.UIOptions', { }); }, + // the token policy can change independently of the tag settings, so refresh only that + // without triggering the resource store refresh a full update does; await this to act on + // a current policy + updateTokenPolicy: async function () { + try { + let { result } = await Proxmox.Async.api2({ url: '/cluster/options', method: 'GET' }); + PVE.UIOptions.options['token-policy'] = result?.data?.['token-policy']; + } catch { + // best effort, keep the last known policy, the backend enforces it in any case + } + }, + tagList: [], updateTagList: function (tags) { diff --git a/www/manager6/dc/OptionView.js b/www/manager6/dc/OptionView.js index 595a8f070..c3e97cf80 100644 --- a/www/manager6/dc/OptionView.js +++ b/www/manager6/dc/OptionView.js @@ -724,6 +724,7 @@ Ext.define('PVE.dc.OptionView', { } PVE.UIOptions.options['tag-style'] = store.getById('tag-style')?.data?.value; + PVE.UIOptions.options['token-policy'] = store.getById('token-policy')?.data?.value; PVE.UIOptions.updateTagSettings(PVE.UIOptions.options['tag-style']); PVE.UIOptions.fireUIConfigChanged(); }); diff --git a/www/manager6/dc/TokenEdit.js b/www/manager6/dc/TokenEdit.js index 2baeb5b74..767e09219 100644 --- a/www/manager6/dc/TokenEdit.js +++ b/www/manager6/dc/TokenEdit.js @@ -40,6 +40,14 @@ Ext.define('PVE.dc.TokenEdit', { } } + // sub-day maximum lifetimes cannot be expressed with the day-granular date + // field, so a lifetime in seconds is entered instead and resolved to an + // absolute expiration date here, as late as possible to not lose validity time + let lifetimeField = me.down('field[name=token-lifetime]'); + if (!lifetimeField.isDisabled() && lifetimeField.getValue()) { + values.expire = Math.floor(Date.now() / 1000) + lifetimeField.getValue(); + } + win.url += `${uid}/token/${tid}`; return values; }, @@ -83,6 +91,17 @@ Ext.define('PVE.dc.TokenEdit', { xtype: 'pmxExpireDate', name: 'expire', }, + { + // used instead of the date field for sub-day maximum lifetimes + xtype: 'numberfield', + name: 'token-lifetime', + submitValue: false, + hidden: true, + disabled: true, + allowDecimals: false, + minValue: 1, + fieldLabel: gettext('Lifetime (seconds)'), + }, ], columnB: [ { @@ -98,10 +117,96 @@ Ext.define('PVE.dc.TokenEdit', { me.callParent(); - if (!me.isCreate) { + // check any token-policy in the UI already for better UX + let policy = PVE.UIOptions.options['token-policy'] || {}; + let maxLifetime = + policy['max-lifetime'] !== undefined ? Number(policy['max-lifetime']) : undefined; + // compute the bound once so the pre-filled default cannot drift past the max + let maxDate = + maxLifetime !== undefined ? new Date(Date.now() + maxLifetime * 1000) : undefined; + // for a sub-day maximum the day-granular date field is no use, at best one future + // midnight fits the bound and covers only an arbitrary fraction of it; a one day + // maximum can even lack such a midnight on the 25 hour day of a DST transition, so + // switch to a seconds-granular lifetime field in both cases + let useLifetimeField = false; + if (maxDate !== undefined) { + let nextMidnight = new Date(); + nextMidnight.setHours(24, 0, 0, 0); + useLifetimeField = maxLifetime < 86400 || nextMidnight > maxDate; + } + + let expireField = me.down('field[name=expire]'); + let lifetimeField = me.down('field[name=token-lifetime]'); + + if (me.isCreate) { + if (useLifetimeField) { + expireField.setVisible(false); + expireField.setDisabled(true); + lifetimeField.setVisible(true); + lifetimeField.setDisabled(false); + lifetimeField.setMaxValue(maxLifetime); + lifetimeField.allowBlank = false; + } else { + if (maxDate !== undefined) { + expireField.setMaxValue(maxDate); + expireField.setValue(maxDate); + } + if (Number(policy['require-expiry']) || maxLifetime !== undefined) { + expireField.allowBlank = false; + } + } + if (Number(policy['require-privilege-separation'])) { + let privilegeSeparationField = me.down('field[name=privsep]'); + privilegeSeparationField.setValue(true); + privilegeSeparationField.setDisabled(true); + } + } else { me.load({ success: function (response, options) { me.setValues(response.result.data); + // a compliant privilege separation setting cannot be disabled again, + // lock it like on create; enabling it on a legacy token stays possible + if (Number(policy['require-privilege-separation'])) { + let privilegeSeparationField = me.down('field[name=privsep]'); + if (privilegeSeparationField.getValue()) { + privilegeSeparationField.setDisabled(true); + } + } + if (Number(policy['disallow-expiry-changes'])) { + // the policy forbids changing the expiration date; a disabled field + // is not submitted, so the stored value stays untouched + expireField.setDisabled(true); + return; + } + if (useLifetimeField) { + // keep the stored date visible but read-only; a new lifetime + // relative to now can be entered in seconds, empty keeps the + // current expiration date + expireField.setDisabled(true); + lifetimeField.setVisible(true); + lifetimeField.setDisabled(false); + lifetimeField.setMaxValue(maxLifetime); + lifetimeField.setEmptyText(gettext('unchanged')); + return; + } + // clearing an existing expiration date would submit a change the policy + // rejects, so require a value then; a token without any date stays valid + // as blank, unchanged it is dropped from submission and not enforced + if ( + me.originalExpire && + (Number(policy['require-expiry']) || maxLifetime !== undefined) + ) { + expireField.allowBlank = false; + } + // bound prolonging only if the current date conforms; else the form would + // be stuck invalid, blocking edits the backend allows for tokens that + // predate the policy (it only enforces dimensions that actually change) + if (maxDate !== undefined) { + let current = expireField.getValue(); + if (!current || current <= maxDate) { + expireField.setMaxValue(maxDate); + } + } }, }); } diff --git a/www/manager6/dc/TokenView.js b/www/manager6/dc/TokenView.js index 8272729af..e228947dc 100644 --- a/www/manager6/dc/TokenView.js +++ b/www/manager6/dc/TokenView.js @@ -61,20 +61,32 @@ Ext.define('PVE.dc.TokenView', { return userid === Proxmox.UserName || !!caps.access['User.Modify']; }; - let run_editor = function (rec) { - if (!hasTokenCRUDPermissions(rec.data.userid)) { - return; + let openPending = false; + let run_token_editor = async function (editorConfig, values) { + if (openPending) { + return; // a slow policy refresh must not spawn duplicate windows + } + openPending = true; + // fetch the current policy first, the dialog adapts its fields to it + await PVE.UIOptions.updateTokenPolicy(); + openPending = false; + if (me.destroyed || !me.isVisible(true)) { + return; // the user navigated away while the policy was being fetched } - let win = Ext.create('PVE.dc.TokenEdit', { - method: 'PUT', - url: urlFromRecord(rec), - }); - win.setValues(rec.data); + let win = Ext.create('PVE.dc.TokenEdit', editorConfig); + win.setValues(values); win.on('destroy', reload); win.show(); }; + let run_editor = function (rec) { + if (!hasTokenCRUDPermissions(rec.data.userid)) { + return; + } + run_token_editor({ method: 'PUT', url: urlFromRecord(rec) }, rec.data); + }; + let regenerate_token = function (_btn, _event, rec) { if (!hasTokenCRUDPermissions(rec.data.userid)) { return; @@ -99,13 +111,7 @@ Ext.define('PVE.dc.TokenView', { { text: gettext('Add'), handler: function (btn, e) { - let data = {}; - let win = Ext.create('PVE.dc.TokenEdit', { - isCreate: true, - }); - win.setValues(data); - win.on('destroy', reload); - win.show(); + run_token_editor({ isCreate: true }, {}); }, }, { -- 2.47.3