[MM-68421] Validate PAT expiry inline before submit

Surface the expiry validation error in the create-token form and disable
Save while the selection is invalid, instead of only failing inside the
create-confirmation flow. Previously a system admin had to click Save then
"Yes, Create" before seeing "An expiry date is required." for an empty
custom date.

Extracts the expiry checks into getExpiryValidationError(), reuses it as
the handleCreateToken guard, and renders the result inline + in the Save
button's disabled condition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Schumacher
2026-06-11 15:03:18 +02:00
parent e16779a3fe
commit 7ccd65eacd
2 changed files with 68 additions and 33 deletions
@@ -257,6 +257,32 @@ describe('UserAccessTokenSection component', () => {
clickSave();
expect(props.actions.createUserAccessToken).not.toHaveBeenCalled();
});
test('surfaces the expiry error inline and disables Save when a custom date is cleared, without clicking Save', () => {
const {container} = renderSection();
startCreating();
change(container, '#newTokenDescription', 'my token');
change(container, '#newTokenExpiry', 'custom');
change(container, '#newTokenExpiryCustom', '');
// The error appears and Save is disabled without the user having to click
// through Save and the create-confirmation modal first.
expect(screen.getByText('An expiry date is required.')).toBeInTheDocument();
expect(screen.getByText('Save').closest('button')).toBeDisabled();
});
test('re-enables Save and clears the inline error once a valid custom date is entered', () => {
const {container} = renderSection({maxLifetimeDays: 30});
startCreating();
change(container, '#newTokenDescription', 'my token');
change(container, '#newTokenExpiry', 'custom');
change(container, '#newTokenExpiryCustom', '');
expect(screen.getByText('Save').closest('button')).toBeDisabled();
change(container, '#newTokenExpiryCustom', '2026-06-20');
expect(screen.queryByText('An expiry date is required.')).not.toBeInTheDocument();
expect(screen.getByText('Save').closest('button')).toBeEnabled();
});
});
describe('expiry enforcement (implied by maxLifetimeDays > 0)', () => {
@@ -257,6 +257,34 @@ class UserAccessTokenSection extends React.PureComponent<Props, State> {
return endOfLocalDayPlusDays(PRESET_DAYS[expiryPreset]);
};
// Validates the current expiry selection and returns a localized error message,
// or null when the selection is valid. Used both to disable the Save button and
// surface the error inline (so the user sees it without clicking through the
// create-confirmation flow) and as the guard in handleCreateToken.
getExpiryValidationError = (): React.ReactNode | null => {
const {maxLifetimeDays} = this.props;
const enforceExpiry = maxLifetimeDays > 0;
const {expiryPreset} = this.state;
const expiresAt = this.resolveExpiresAt();
if (expiryPreset === 'custom' && expiresAt <= 0) {
return mapServerErrorIdToMessage('expires_at_required');
}
if (enforceExpiry && expiresAt <= 0) {
return mapServerErrorIdToMessage('expires_at_required');
}
if (expiresAt > 0 && expiresAt <= Date.now()) {
return mapServerErrorIdToMessage('expires_at_in_past');
}
if (expiresAt > 0 && maxLifetimeDays > 0) {
const maxAllowed = endOfLocalDayPlusDays(maxLifetimeDays);
if (expiresAt > maxAllowed) {
return mapServerErrorIdToMessage('expires_at_too_far', maxLifetimeDays);
}
}
return null;
};
focusEditButton(): void {
this.minRef.current?.focus();
}
@@ -308,38 +336,14 @@ class UserAccessTokenSection extends React.PureComponent<Props, State> {
return;
}
const {maxLifetimeDays} = this.props;
const enforceExpiry = maxLifetimeDays > 0;
const {expiryPreset} = this.state;
const expiresAt = this.resolveExpiresAt();
const expiryError = this.getExpiryValidationError();
if (expiryError) {
this.setState({tokenError: expiryError});
return;
}
if (expiryPreset === 'custom' && expiresAt <= 0) {
this.setState({
tokenError: mapServerErrorIdToMessage('expires_at_required'),
});
return;
}
if (enforceExpiry && expiresAt <= 0) {
this.setState({
tokenError: mapServerErrorIdToMessage('expires_at_required'),
});
return;
}
if (expiresAt > 0 && expiresAt <= Date.now()) {
this.setState({
tokenError: mapServerErrorIdToMessage('expires_at_in_past'),
});
return;
}
if (expiresAt > 0 && maxLifetimeDays > 0) {
const maxAllowed = endOfLocalDayPlusDays(maxLifetimeDays);
if (expiresAt > maxAllowed) {
this.setState({
tokenError: mapServerErrorIdToMessage('expires_at_too_far', maxLifetimeDays),
});
return;
}
}
const {maxLifetimeDays} = this.props;
const expiresAt = this.resolveExpiresAt();
this.setState({tokenError: '', saving: true});
this.props.setRequireConfirm(true, this.confirmCopyToken);
@@ -802,6 +806,11 @@ class UserAccessTokenSection extends React.PureComponent<Props, State> {
const {expiryPreset, customExpiryDate} = this.state;
const maxCustomIso = maxLifetimeDays > 0 ? isoPlusDays(maxLifetimeDays) : undefined;
// Validate the expiry selection up front so the error surfaces inline and the
// Save button is disabled, instead of only failing inside the confirm flow.
const expiryError = this.getExpiryValidationError();
const descriptionEmpty = this.state.tokenDescription.trim() === '';
const expirySection = (
<div className='row pt-3'>
<label
@@ -926,7 +935,7 @@ class UserAccessTokenSection extends React.PureComponent<Props, State> {
id='clientError'
className='has-error mt-2 mb-2'
>
{this.state.tokenError}
{this.state.tokenError || expiryError}
</label>
</div>
<SaveButton
@@ -937,7 +946,7 @@ class UserAccessTokenSection extends React.PureComponent<Props, State> {
/>
}
saving={this.state.saving}
disabled={this.state.tokenDescription.trim() === ''}
disabled={descriptionEmpty || Boolean(expiryError)}
onClick={this.confirmCreateToken}
/>
<Button