From 49cdd4c993057f37073a7cf9ec00dc83530e7620 Mon Sep 17 00:00:00 2001 From: James Cocker Date: Tue, 28 Apr 2026 14:22:03 +0100 Subject: [PATCH 01/13] Can create person access token with a token lifespan Signed-off-by: James Cocker --- galasa-ui/messages/de.json | 8 +- galasa-ui/messages/en.json | 6 + galasa-ui/src/app/auth/tokens/route.ts | 6 +- .../components/tokens/TokenRequestModal.tsx | 83 ++++++++++- galasa-ui/src/proxy.ts | 18 ++- .../tokens/TokenRequestModal.module.css | 10 ++ .../tokens/TokenRequestModal.test.tsx | 134 ++++++++++++++++++ galasa-ui/src/tests/routes/authTokens.test.ts | 3 + galasa-ui/src/utils/authCookies.ts | 1 + 9 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 galasa-ui/src/styles/tokens/TokenRequestModal.module.css diff --git a/galasa-ui/messages/de.json b/galasa-ui/messages/de.json index 858d8643..3e524b73 100644 --- a/galasa-ui/messages/de.json +++ b/galasa-ui/messages/de.json @@ -117,6 +117,12 @@ "token_name": "Tokenname", "token_name_helper_text": "Hilft beim Wiedererkennen Ihrer Token.", "token_name_placeholder": "z.B. galasactl Zugriff auf Windows", + "token_lifespan": "Token-Lebensdauer", + "select_lifespan": "Token-Lebensdauer auswählen", + "custom_lifespan": "Benutzerdefiniert", + "custom_lifespan_days": "Benutzerdefinierte Lebensdauer (Tage)", + "custom_lifespan_helper_text": "Geben Sie einen Wert zwischen 1 und 365 Tagen ein", + "custom_lifespan_invalid": "Die Lebensdauer muss zwischen 1 und 365 Tagen liegen", "error_requesting_token": "Fehler beim Anfordern des Tokens" }, "TokenCard": { @@ -589,4 +595,4 @@ "deleteMessage": "Die Abfrage \"{name}\" wurde erfolgreich gelöscht.", "duplicateMessage": "Die Abfrage \"{name}\" wurde erfolgreich dupliziert." } -} +} \ No newline at end of file diff --git a/galasa-ui/messages/en.json b/galasa-ui/messages/en.json index 8b50e56a..be4fce8f 100644 --- a/galasa-ui/messages/en.json +++ b/galasa-ui/messages/en.json @@ -117,6 +117,12 @@ "token_name": "Token name", "token_name_helper_text": "Use this to distinguish between your tokens in the future.", "token_name_placeholder": "e.g. galasactl access for my Windows machine", + "token_lifespan": "Token lifespan", + "select_lifespan": "Select token lifespan", + "custom_lifespan": "Custom", + "custom_lifespan_days": "Custom lifespan (days)", + "custom_lifespan_helper_text": "Enter a value between 1 and 365 days", + "custom_lifespan_invalid": "Lifespan must be between 1 and 365 days", "error_requesting_token": "Error requesting access token" }, "TokenCard": { diff --git a/galasa-ui/src/app/auth/tokens/route.ts b/galasa-ui/src/app/auth/tokens/route.ts index d6cb410b..04060725 100644 --- a/galasa-ui/src/app/auth/tokens/route.ts +++ b/galasa-ui/src/app/auth/tokens/route.ts @@ -15,6 +15,7 @@ export const dynamic = 'force-dynamic'; interface TokenDetails { tokenDescription: string; + token_lifespan_days: number; } // POST request handler for requests to /auth/tokens @@ -33,11 +34,14 @@ export async function POST(request: NextRequest) { // Store the client ID to be displayed to the user later cookiesStore.set(AuthCookies.CLIENT_ID, clientId, { httpOnly: true }); - // Store the token description to be passed to the API server on the callback + // Store the token description and lifespan to be passed to the API server on the callback const requestBody: TokenDetails = await request.json(); cookiesStore.set(AuthCookies.TOKEN_DESCRIPTION, requestBody.tokenDescription, { httpOnly: true, }); + cookiesStore.set(AuthCookies.TOKEN_LIFESPAN_DAYS, String(requestBody.token_lifespan_days), { + httpOnly: true, + }); // Authenticate with the created client to get a new refresh token for this client const authResponse = await sendAuthRequest(clientId); diff --git a/galasa-ui/src/components/tokens/TokenRequestModal.tsx b/galasa-ui/src/components/tokens/TokenRequestModal.tsx index ae3a1ef5..28a4130c 100644 --- a/galasa-ui/src/components/tokens/TokenRequestModal.tsx +++ b/galasa-ui/src/components/tokens/TokenRequestModal.tsx @@ -5,12 +5,18 @@ */ 'use client'; -import { Button, Modal } from '@carbon/react'; +import { Button, Modal, Dropdown, NumberInput } from '@carbon/react'; import { useRef, useState } from 'react'; import { TextInput } from '@carbon/react'; import { InlineNotification } from '@carbon/react'; import { Add } from '@carbon/icons-react'; import { useTranslations } from 'next-intl'; +import styles from '@/styles/tokens/TokenRequestModal.module.css'; + +const PRESET_LIFESPANS = [7, 30, 90]; +const CUSTOM_VALUE_ID = 'custom'; +const MIN_LIFESPAN = 1; +const MAX_LIFESPAN = 365; export default function TokenRequestModal({ isDisabled }: { isDisabled: boolean }) { const translations = useTranslations('TokenRequestModal'); @@ -18,11 +24,40 @@ export default function TokenRequestModal({ isDisabled }: { isDisabled: boolean const [open, setOpen] = useState(false); const [error, setError] = useState(''); const [submitDisabled, setSubmitDisabled] = useState(true); + const [selectedLifespan, setSelectedLifespan] = useState(String(PRESET_LIFESPANS[0])); + const [customLifespan, setCustomLifespan] = useState(7); const tokenNameInputRef = useRef(undefined); + const getExpiryDate = (days: number): string => { + const date = new Date(); + date.setDate(date.getDate() + days); + return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'long' }); + }; + + const getLifespanOptions = () => { + const options = PRESET_LIFESPANS.map((days) => ({ + id: String(days), + label: `${days} days (${getExpiryDate(days)})`, + })); + options.push({ + id: CUSTOM_VALUE_ID, + label: translations('custom_lifespan'), + }); + return options; + }; + + const getEffectiveLifespan = (): number => { + if (selectedLifespan === CUSTOM_VALUE_ID) { + return customLifespan; + } + return parseInt(selectedLifespan, 10); + }; + const onChangeInputValidation = () => { const tokenName = tokenNameInputRef.current?.value.trim() ?? ''; - setSubmitDisabled(!tokenName); + const lifespan = getEffectiveLifespan(); + const isLifespanValid = lifespan >= MIN_LIFESPAN && lifespan <= MAX_LIFESPAN; + setSubmitDisabled(!tokenName || !isLifespanValid); }; const submitTokenRequest = async () => { @@ -31,6 +66,7 @@ export default function TokenRequestModal({ isDisabled }: { isDisabled: boolean method: 'POST', body: JSON.stringify({ tokenDescription: tokenNameInputRef.current?.value.trim(), + token_lifespan_days: getEffectiveLifespan(), }), }); @@ -72,9 +108,12 @@ export default function TokenRequestModal({ isDisabled }: { isDisabled: boolean secondaryButtonText={translations('cancel')} shouldSubmitOnEnter={true} open={open} + className={styles.modalContent} onRequestClose={() => { setOpen(false); setError(''); + setSelectedLifespan(String(PRESET_LIFESPANS[0])); + setCustomLifespan(7); }} onRequestSubmit={async () => { if (!submitDisabled) { @@ -99,6 +138,46 @@ export default function TokenRequestModal({ isDisabled }: { isDisabled: boolean placeholder={translations('token_name_placeholder')} onChange={onChangeInputValidation} /> + +
+ + (item ? item.label : '')} + selectedItem={getLifespanOptions().find((opt) => opt.id === selectedLifespan)} + onChange={({ selectedItem }: { selectedItem?: { id: string; label: string } }) => { + if (selectedItem) { + setSelectedLifespan(selectedItem.id); + onChangeInputValidation(); + } + }} + /> + + {selectedLifespan === CUSTOM_VALUE_ID && ( + <> +
+ { + if (value !== undefined && value !== null) { + setCustomLifespan(value); + onChangeInputValidation(); + } + }} + invalidText={translations('custom_lifespan_invalid')} + invalid={customLifespan < MIN_LIFESPAN || customLifespan > MAX_LIFESPAN} + /> + + )} + {error && ( { const tokenDescription = request.cookies.get(AuthCookies.TOKEN_DESCRIPTION)?.value; response.cookies.delete(AuthCookies.TOKEN_DESCRIPTION); + const tokenLifespanDays = request.cookies.get(AuthCookies.TOKEN_LIFESPAN_DAYS)?.value; + response.cookies.delete(AuthCookies.TOKEN_LIFESPAN_DAYS); + // Build the request body - const authProperties = buildAuthProperties(clientId, code, tokenDescription); + const authProperties = buildAuthProperties( + clientId, + code, + tokenDescription, + tokenLifespanDays ? parseInt(tokenLifespanDays, 10) : undefined + ); // Send a POST request to the API server's /auth endpoint to exchange the authorization code with a JWT const tokenResponse = await authApiClient.createToken(authProperties); @@ -199,12 +207,18 @@ const handleCallback = async (request: NextRequest, response: NextResponse) => { return response; }; -const buildAuthProperties = (clientId: string, code: string, tokenDescription?: string) => { +const buildAuthProperties = ( + clientId: string, + code: string, + tokenDescription?: string, + tokenLifespanDays?: number +) => { const authProperties = new AuthProperties(); authProperties.clientId = clientId; authProperties.code = code; authProperties.description = tokenDescription; + authProperties.tokenLifespanDays = tokenLifespanDays; return authProperties; }; diff --git a/galasa-ui/src/styles/tokens/TokenRequestModal.module.css b/galasa-ui/src/styles/tokens/TokenRequestModal.module.css new file mode 100644 index 00000000..73c9cd9e --- /dev/null +++ b/galasa-ui/src/styles/tokens/TokenRequestModal.module.css @@ -0,0 +1,10 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ + +/* Override Carbon modal's overflow behavior to allow dropdown to expand below container */ +.modalContent :global(.cds--modal-content) { + overflow: visible; +} diff --git a/galasa-ui/src/tests/components/tokens/TokenRequestModal.test.tsx b/galasa-ui/src/tests/components/tokens/TokenRequestModal.test.tsx index 96bdb6a3..e5bbbcc3 100644 --- a/galasa-ui/src/tests/components/tokens/TokenRequestModal.test.tsx +++ b/galasa-ui/src/tests/components/tokens/TokenRequestModal.test.tsx @@ -19,6 +19,12 @@ jest.mock('next-intl', () => ({ token_name: 'Token name', token_name_helper_text: 'Use this to distinguish between your tokens in the future.', token_name_placeholder: 'e.g. galasactl access for my Windows machine', + token_lifespan: 'Token lifespan', + select_lifespan: 'Select token lifespan', + custom_lifespan: 'Custom', + custom_lifespan_days: 'Custom lifespan (days)', + custom_lifespan_helper_text: 'Enter a value between 1 and 365 days', + custom_lifespan_invalid: 'Lifespan must be between 1 and 365 days', error_requesting_token: 'Error requesting access token', }; return translations[key] || key; @@ -328,4 +334,132 @@ describe('Token request modal', () => { // Then... expect(global.fetch).not.toHaveBeenCalled(); }); + + it('renders token lifespan dropdown with default value', async () => { + // Given... + await act(async () => { + render(); + }); + + const openModalButtonElement = screen.getByRole('token-request-btn'); + + // When... + fireEvent.click(openModalButtonElement); + + // Then... + const lifespanDropdown = screen.getByText(/Token lifespan/i); + expect(lifespanDropdown).toBeInTheDocument(); + }); + + it('includes token_lifespan_days in POST request with default value', async () => { + // Given... + const redirectUrl = 'http://my-connector/auth'; + + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ url: redirectUrl }), + }) + ) as jest.Mock; + + await act(async () => { + render(); + }); + + const openModalButtonElement = screen.getByRole('token-request-btn'); + const modalCreateButtonElement = screen.getByText(/^Create$/); + const modalNameInputElement = screen.getByLabelText(/Token Name/i); + + // When... + fireEvent.click(openModalButtonElement); + fireEvent.input(modalNameInputElement, { target: { value: 'test-token' } }); + fireEvent.click(modalCreateButtonElement); + + // Then... + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1)); + expect(global.fetch).toHaveBeenCalledWith( + '/auth/tokens', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + tokenDescription: 'test-token', + token_lifespan_days: 7, + }), + }) + ); + }); + + it('shows custom lifespan input when Custom is selected', async () => { + // Given... + await act(async () => { + render(); + }); + + const openModalButtonElement = screen.getByRole('token-request-btn'); + + // When... + fireEvent.click(openModalButtonElement); + + // Find and click the dropdown to open it + const dropdownButton = screen.getByRole('combobox'); + fireEvent.click(dropdownButton); + + // Select the Custom option + const customOption = screen.getByText(/^Custom$/); + fireEvent.click(customOption); + + // Then... + await waitFor(() => { + const customInput = screen.getByLabelText(/Custom lifespan \(days\)/i); + expect(customInput).toBeInTheDocument(); + }); + }); + + it('includes custom token_lifespan_days in POST request', async () => { + // Given... + const redirectUrl = 'http://my-connector/auth'; + const customDays = 45; + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ url: redirectUrl }), + }) + ) as jest.Mock; + await act(async () => { + render(); + }); + const openModalButtonElement = screen.getByRole('token-request-btn'); + const modalNameInputElement = screen.getByLabelText(/Token Name/i); + + // When... + fireEvent.click(openModalButtonElement); + fireEvent.input(modalNameInputElement, { target: { value: 'test-token' } }); + + // Select Custom from dropdown + const dropdownButton = screen.getByRole('combobox'); + fireEvent.click(dropdownButton); + const customOption = screen.getByText(/^Custom$/); + fireEvent.click(customOption); + + // Enter custom value + await waitFor(() => { + const customInput = screen.getByLabelText(/Custom lifespan \(days\)/i); + fireEvent.change(customInput, { target: { value: customDays } }); + }); + const modalCreateButtonElement = screen.getByText(/^Create$/); + fireEvent.click(modalCreateButtonElement); + + // Then... + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1)); + expect(global.fetch).toHaveBeenCalledWith( + '/auth/tokens', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + tokenDescription: 'test-token', + token_lifespan_days: customDays, + }), + }) + ); + }); }); diff --git a/galasa-ui/src/tests/routes/authTokens.test.ts b/galasa-ui/src/tests/routes/authTokens.test.ts index 3f681317..50571dad 100644 --- a/galasa-ui/src/tests/routes/authTokens.test.ts +++ b/galasa-ui/src/tests/routes/authTokens.test.ts @@ -65,6 +65,7 @@ describe('POST /auth/tokens', () => { const requestBody = JSON.stringify({ tokenDescription: 'my-token', + token_lifespan_days: 7, }); const request = new NextRequest('https://my-server/auth/tokens', { @@ -95,6 +96,7 @@ describe('POST /auth/tokens', () => { const requestBody = JSON.stringify({ tokenDescription: 'my-token', + token_lifespan_days: 7, }); const request = new NextRequest('https://my-server/auth/tokens', { @@ -124,6 +126,7 @@ describe('POST /auth/tokens', () => { const requestBody = JSON.stringify({ tokenDescription: 'my-token', + token_lifespan_days: 7, }); const request = new NextRequest('https://my-server/auth/tokens', { diff --git a/galasa-ui/src/utils/authCookies.ts b/galasa-ui/src/utils/authCookies.ts index 9997eff0..40578b1b 100644 --- a/galasa-ui/src/utils/authCookies.ts +++ b/galasa-ui/src/utils/authCookies.ts @@ -9,6 +9,7 @@ const AuthCookies = { REFRESH_TOKEN: 'refresh_token', ID_TOKEN: 'id_token', TOKEN_DESCRIPTION: 'token_description', + TOKEN_LIFESPAN_DAYS: 'token_lifespan_days', SHOULD_REDIRECT_TO_SETTINGS: 'should_redirect_to_settings', }; From ec6c5265a0ecadde19bd493d8a74d0a476a5527b Mon Sep 17 00:00:00 2001 From: James Cocker Date: Tue, 28 Apr 2026 15:20:19 +0100 Subject: [PATCH 02/13] Display expiry date Signed-off-by: James Cocker --- galasa-ui/messages/de.json | 1 + galasa-ui/messages/en.json | 1 + galasa-ui/src/components/tokens/TokenCard.tsx | 12 ++++++++++-- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/galasa-ui/messages/de.json b/galasa-ui/messages/de.json index 3e524b73..89ea9afa 100644 --- a/galasa-ui/messages/de.json +++ b/galasa-ui/messages/de.json @@ -127,6 +127,7 @@ }, "TokenCard": { "createdAt": "Erstellt am:", + "expires": "Läuft ab:", "owner": "Inhaber" }, "TokenDeleteModal": { diff --git a/galasa-ui/messages/en.json b/galasa-ui/messages/en.json index be4fce8f..bd087ae9 100644 --- a/galasa-ui/messages/en.json +++ b/galasa-ui/messages/en.json @@ -127,6 +127,7 @@ }, "TokenCard": { "createdAt": "Created at:", + "expires": "Expires:", "owner": "Owner" }, "TokenDeleteModal": { diff --git a/galasa-ui/src/components/tokens/TokenCard.tsx b/galasa-ui/src/components/tokens/TokenCard.tsx index f46464ae..3303c759 100644 --- a/galasa-ui/src/components/tokens/TokenCard.tsx +++ b/galasa-ui/src/components/tokens/TokenCard.tsx @@ -22,9 +22,12 @@ function TokenCard({ }) { const translations = useTranslations('TokenCard'); const { formatDate } = useDateTimeFormat(); - const formattedDate = useMemo(() => { + const formattedCreationDate = useMemo(() => { return formatDate(new Date(token.creationTime!)); }, [token.creationTime, formatDate]); + const formattedExpiryDate = useMemo(() => { + return formatDate(new Date(token.expiryTime!)); + }, [token.expiryTime, formatDate]); return (
- {translations('createdAt')} {formattedDate} + {translations('createdAt')} {formattedCreationDate}
+ { formattedExpiryDate && +
+ {translations('expires')} {formattedExpiryDate} +
+ }
{translations('owner')} {token.owner?.loginId}
From 05cd1141a7abb9c2df6cb2b7c10bab3c65b859af Mon Sep 17 00:00:00 2001 From: James Cocker Date: Wed, 29 Apr 2026 10:49:19 +0100 Subject: [PATCH 03/13] Expired tokens greyed out Signed-off-by: James Cocker --- galasa-ui/messages/de.json | 1 + galasa-ui/messages/en.json | 3 ++- galasa-ui/src/components/tokens/TokenCard.tsx | 14 ++++++++++---- galasa-ui/src/styles/tokens/TokenCard.module.css | 10 ++++++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/galasa-ui/messages/de.json b/galasa-ui/messages/de.json index 89ea9afa..7c92af9c 100644 --- a/galasa-ui/messages/de.json +++ b/galasa-ui/messages/de.json @@ -128,6 +128,7 @@ "TokenCard": { "createdAt": "Erstellt am:", "expires": "Läuft ab:", + "expired": "Abgelaufen:", "owner": "Inhaber" }, "TokenDeleteModal": { diff --git a/galasa-ui/messages/en.json b/galasa-ui/messages/en.json index bd087ae9..62920bf2 100644 --- a/galasa-ui/messages/en.json +++ b/galasa-ui/messages/en.json @@ -128,6 +128,7 @@ "TokenCard": { "createdAt": "Created at:", "expires": "Expires:", + "expired": "Expired:", "owner": "Owner" }, "TokenDeleteModal": { @@ -575,4 +576,4 @@ "deleteMessage": "The query \"{name}\" was deleted successfully.", "duplicateMessage": "The query \"{name}\" was duplicated successfully." } -} +} \ No newline at end of file diff --git a/galasa-ui/src/components/tokens/TokenCard.tsx b/galasa-ui/src/components/tokens/TokenCard.tsx index 3303c759..ff999c00 100644 --- a/galasa-ui/src/components/tokens/TokenCard.tsx +++ b/galasa-ui/src/components/tokens/TokenCard.tsx @@ -29,12 +29,18 @@ function TokenCard({ return formatDate(new Date(token.expiryTime!)); }, [token.expiryTime, formatDate]); + // Check if token is expired + const isExpired = useMemo(() => { + if (!token.expiryTime) return false; + return new Date(token.expiryTime) < new Date(); + }, [token.expiryTime]); + return ( selectTokenForDeletion(token.tokenId)} value={true} key={token.tokenId} - className={styles.cardContainer} + className={`${styles.cardContainer} ${isExpired ? styles.cardContainerExpired : ''}`} >
{token.description}
@@ -42,9 +48,9 @@ function TokenCard({
{translations('createdAt')} {formattedCreationDate}
- { formattedExpiryDate && -
- {translations('expires')} {formattedExpiryDate} + {formattedExpiryDate && +
+ {isExpired ? translations('expired') : translations('expires')} {formattedExpiryDate}
}
diff --git a/galasa-ui/src/styles/tokens/TokenCard.module.css b/galasa-ui/src/styles/tokens/TokenCard.module.css index 9e5ba7ca..6bc33c99 100644 --- a/galasa-ui/src/styles/tokens/TokenCard.module.css +++ b/galasa-ui/src/styles/tokens/TokenCard.module.css @@ -9,10 +9,20 @@ width: 500px; } +.cardContainerExpired { + opacity: 0.6; + border-left: 4px solid var(--cds-support-error); +} + .infoContainer { margin: 1rem 0; } +.expiredLabel { + color: var(--cds-support-error); + font-weight: 600; +} + .icon { margin-top: 1rem; } From 2e267db61f85a21797c68faf5648bf5e440f6f56 Mon Sep 17 00:00:00 2001 From: James Cocker Date: Wed, 29 Apr 2026 16:40:09 +0100 Subject: [PATCH 04/13] Added CPS warning Signed-off-by: James Cocker --- galasa-ui/messages/de.json | 7 ++- galasa-ui/messages/en.json | 7 ++- galasa-ui/src/app/mysettings/page.tsx | 45 ++++++++++++++++++- .../mysettings/AccessTokensSection.tsx | 34 +++++++++++++- galasa-ui/src/components/tokens/TokenCard.tsx | 22 ++++++++- .../styles/mysettings/MySettings.module.css | 4 ++ .../src/styles/tokens/TokenCard.module.css | 20 ++++++++- galasa-ui/src/utils/constants/common.ts | 9 ++++ 8 files changed, 138 insertions(+), 10 deletions(-) diff --git a/galasa-ui/messages/de.json b/galasa-ui/messages/de.json index 7c92af9c..317334b3 100644 --- a/galasa-ui/messages/de.json +++ b/galasa-ui/messages/de.json @@ -62,7 +62,9 @@ "descriptionline1": "Ein Zugriffstoken ist ein eindeutiger geheimer Schlüssel, der von einem Client-Programm gehalten wird, um Zugriff auf den Galasa-Dienst zu erhalten.", "descriptionline2": "Ein Token hat die gleichen Zugriffsrechte wie der Benutzer, der es erstellt hat.", "deleteButtontext": "{count} ausgewählte Zugriffstoken löschen", - "error": "Fehler beim Abrufen der Token vom Galasa API-Server" + "error": "Fehler beim Abrufen der Token vom Galasa API-Server", + "warningDaysMaximumTitle": "Limit für Token-Ablaufwarnung angewendet", + "warningDaysMaximumSubtitle": "Der maximale Wert für service.tokens.lifespan.nearly.expired.warning.days beträgt 30 Tage." }, "ResultsTablePageSizingSetting": { "title": "Testergebnisabfrage", @@ -129,7 +131,8 @@ "createdAt": "Erstellt am:", "expires": "Läuft ab:", "expired": "Abgelaufen:", - "owner": "Inhaber" + "owner": "Inhaber", + "nearlyExpiredWarning": "Ihr persönliches Zugriffstoken läuft bald ab . Nach dem Ablauf können Sie es nicht mehr verwenden, um Ihren Galasa-Dienst zu kontaktieren." }, "TokenDeleteModal": { "modalHeading": "Zugriffstoken löschen", diff --git a/galasa-ui/messages/en.json b/galasa-ui/messages/en.json index 62920bf2..319b13a7 100644 --- a/galasa-ui/messages/en.json +++ b/galasa-ui/messages/en.json @@ -62,7 +62,9 @@ "descriptionline1": "An access token is a unique secret key held by a client program so it has permission to use the Galasa service.", "descriptionline2": "A token has the same access rights as the user who allocated it.", "deleteButtontext": "Delete {count} selected access token", - "error": "Failed to fetch tokens from the Galasa API server" + "error": "Failed to fetch tokens from the Galasa API server", + "warningDaysMaximumTitle": "Token expiry warning days limit applied", + "warningDaysMaximumSubtitle": "The maximum value for service.tokens.lifespan.nearly.expired.warning.days is 30 days." }, "ResultsTablePageSizingSetting": { "title": "Test Run Query Results", @@ -129,7 +131,8 @@ "createdAt": "Created at:", "expires": "Expires:", "expired": "Expired:", - "owner": "Owner" + "owner": "Owner", + "nearlyExpiredWarning": "Your personal access token will expire soon . Once it expires, you will no longer be able to use it to contact your Galasa service." }, "TokenDeleteModal": { "modalHeading": "Delete Access Tokens", diff --git a/galasa-ui/src/app/mysettings/page.tsx b/galasa-ui/src/app/mysettings/page.tsx index c970007d..1c246b00 100644 --- a/galasa-ui/src/app/mysettings/page.tsx +++ b/galasa-ui/src/app/mysettings/page.tsx @@ -8,7 +8,7 @@ import { cookies } from 'next/headers'; import AccessTokensSection from '@/components/mysettings/AccessTokensSection'; import TokenResponseModal from '@/components/tokens/TokenResponseModal'; import PageTile from '@/components/PageTile'; -import { UsersAPIApi } from '@/generated/galasaapi'; +import { ConfigurationPropertyStoreAPIApi, UsersAPIApi } from '@/generated/galasaapi'; import { createAuthenticatedApiConfiguration } from '@/utils/api'; import * as Constants from '@/utils/constants/common'; import BreadCrumb from '@/components/common/BreadCrumb'; @@ -20,6 +20,30 @@ import { fetchUserFromApiServer } from '@/actions/userServerActions'; import ProfileRole from '@/components/users/UserRole'; import DateTimeSettings from '@/components/mysettings/DateTimeSettings'; import ResultsTablePageSizeSetting from '@/components/mysettings/ResultsTablePageSizeSetting'; + +const getValidatedWarningDays = (value?: string) => { + const parsedValue = Number.parseInt(value ?? '', 10); + + if (Number.isNaN(parsedValue) || parsedValue < 0) { + return { + warningDays: Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_WARNING_DAYS, + exceededMaximum: false, + }; + } + + if (parsedValue > Constants.MAX_ACCESS_TOKEN_EXPIRY_WARNING_DAYS) { + return { + warningDays: Constants.MAX_ACCESS_TOKEN_EXPIRY_WARNING_DAYS, + exceededMaximum: true, + }; + } + + return { + warningDays: parsedValue, + exceededMaximum: false, + }; +}; + export default async function MySettings() { const apiConfig = createAuthenticatedApiConfiguration(); @@ -48,6 +72,21 @@ export default async function MySettings() { return loginId; }; + const fetchTokenExpiryWarningConfiguration = async () => { + try { + const cpsApiClientWithAuthHeader = new ConfigurationPropertyStoreAPIApi(apiConfig); + const warningPropertyResponse = await cpsApiClientWithAuthHeader.getCpsProperty( + Constants.ACCESS_TOKEN_EXPIRY_WARNING_PROPERTY_NAMESPACE, + Constants.ACCESS_TOKEN_EXPIRY_WARNING_PROPERTY_NAME + ); + + const warningPropertyValue = warningPropertyResponse?.[0]?.data?.value; + return getValidatedWarningDays(warningPropertyValue); + } catch (error) { + return getValidatedWarningDays(); + } + }; + // Await the login ID before using it const userLoginId = await fetchUserLoginId(); @@ -55,6 +94,8 @@ export default async function MySettings() { return ; } + const tokenExpiryWarningConfiguration = await fetchTokenExpiryWarningConfiguration(); + return (
@@ -63,6 +104,8 @@ export default async function MySettings() { diff --git a/galasa-ui/src/components/mysettings/AccessTokensSection.tsx b/galasa-ui/src/components/mysettings/AccessTokensSection.tsx index 62dc01d2..69125761 100644 --- a/galasa-ui/src/components/mysettings/AccessTokensSection.tsx +++ b/galasa-ui/src/components/mysettings/AccessTokensSection.tsx @@ -6,7 +6,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Loading, Button } from '@carbon/react'; +import { Loading, Button, InlineNotification } from '@carbon/react'; import styles from '@/styles/mysettings/MySettings.module.css'; import TokenCard from '@/components/tokens/TokenCard'; import ErrorPage from '@/app/error/page'; @@ -18,11 +18,15 @@ import { useTranslations } from 'next-intl'; interface AccessTokensSectionProps { accessTokensPromise: Promise; isAddBtnVisible: boolean; + tokenExpiryWarningDays: number; + showMaxWarningDaysNotice: boolean; } export default function AccessTokensSection({ accessTokensPromise, isAddBtnVisible, + tokenExpiryWarningDays, + showMaxWarningDaysNotice, }: AccessTokensSectionProps) { const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); @@ -72,13 +76,27 @@ export default function AccessTokensSection({ try { const accessTokens = await accessTokensPromise; + if (accessTokens && accessTokens.tokens) { setTokens(new Set(accessTokens.tokens)); } else { throw new Error(translations('error')); } } catch (err) { - setIsError(true); + if (err instanceof Error && err.message === translations('error')) { + setIsError(true); + } else { + try { + const accessTokens = await accessTokensPromise; + if (accessTokens && accessTokens.tokens) { + setTokens(new Set(accessTokens.tokens)); + } else { + throw new Error(translations('error')); + } + } catch (accessTokenError) { + setIsError(true); + } + } } finally { setIsLoading(false); } @@ -107,6 +125,17 @@ export default function AccessTokensSection({ + {showMaxWarningDaysNotice && ( + + )} +
{/* // Only the user who is logged in can create a new token // Admins can only delete users' tokens. */} @@ -128,6 +157,7 @@ export default function AccessTokensSection({ key={token.tokenId} token={token} selectTokenForDeletion={selectTokenForDeletion} + expiryWarningDays={tokenExpiryWarningDays} /> ))}
diff --git a/galasa-ui/src/components/tokens/TokenCard.tsx b/galasa-ui/src/components/tokens/TokenCard.tsx index ff999c00..89712c4f 100644 --- a/galasa-ui/src/components/tokens/TokenCard.tsx +++ b/galasa-ui/src/components/tokens/TokenCard.tsx @@ -16,9 +16,11 @@ import { useMemo } from 'react'; function TokenCard({ token, selectTokenForDeletion, + expiryWarningDays, }: { token: AuthToken; selectTokenForDeletion: Function; + expiryWarningDays: number; }) { const translations = useTranslations('TokenCard'); const { formatDate } = useDateTimeFormat(); @@ -35,6 +37,17 @@ function TokenCard({ return new Date(token.expiryTime) < new Date(); }, [token.expiryTime]); + const isNearlyExpired = useMemo(() => { + if (!token.expiryTime || isExpired) return false; + + const expiryTime = new Date(token.expiryTime).getTime(); + const currentTime = Date.now(); + // Where 86400000 = 24 hours in milliseconds = 24 * 60 * 60 * 1000 + const warningWindowInMilliseconds = expiryWarningDays * 86400000; + + return expiryTime - currentTime <= warningWindowInMilliseconds; + }, [token.expiryTime, isExpired, expiryWarningDays]); + return ( selectTokenForDeletion(token.tokenId)} @@ -58,7 +71,14 @@ function TokenCard({
- +
+ + {isNearlyExpired && ( +

+ {translations('nearlyExpiredWarning')} +

+ )} +
); } diff --git a/galasa-ui/src/styles/mysettings/MySettings.module.css b/galasa-ui/src/styles/mysettings/MySettings.module.css index 334e8574..5467985c 100644 --- a/galasa-ui/src/styles/mysettings/MySettings.module.css +++ b/galasa-ui/src/styles/mysettings/MySettings.module.css @@ -46,6 +46,10 @@ margin: 1rem 0; } +.warningNotification { + margin-top: 1rem; +} + .experimentalFeaturesHeaderContainer { display: flex; flex-direction: column; diff --git a/galasa-ui/src/styles/tokens/TokenCard.module.css b/galasa-ui/src/styles/tokens/TokenCard.module.css index 6bc33c99..a4ddcc58 100644 --- a/galasa-ui/src/styles/tokens/TokenCard.module.css +++ b/galasa-ui/src/styles/tokens/TokenCard.module.css @@ -15,7 +15,8 @@ } .infoContainer { - margin: 1rem 0; + flex: 1; + min-width: 0; } .expiredLabel { @@ -23,6 +24,21 @@ font-weight: 600; } -.icon { +.iconWarningContainer { margin-top: 1rem; + display: flex; + align-items: flex-start; + gap: 1.5rem; + flex: 0 0 180px; +} + +.expiryWarning { + color: var(--cds-support-warning); + font-size: 0.8rem; + line-height: 1.25; + margin: 0; +} + +.icon { + flex-shrink: 0; } diff --git a/galasa-ui/src/utils/constants/common.ts b/galasa-ui/src/utils/constants/common.ts index 81c297a3..2d7ba5a9 100644 --- a/galasa-ui/src/utils/constants/common.ts +++ b/galasa-ui/src/utils/constants/common.ts @@ -167,6 +167,11 @@ const RESULTS_TABLE_PAGE_SIZES = [10, 20, 30, 40, 50]; const TIME_TO_WAIT_BEFORE_CLOSING_TAG_EDIT_MODAL_MS = 1500; +const DEFAULT_ACCESS_TOKEN_EXPIRY_WARNING_DAYS = 14; +const MAX_ACCESS_TOKEN_EXPIRY_WARNING_DAYS = 30; +const ACCESS_TOKEN_EXPIRY_WARNING_PROPERTY_NAME = 'tokens.lifespan.nearly.expired.warning.days'; +const ACCESS_TOKEN_EXPIRY_WARNING_PROPERTY_NAMESPACE = 'service'; + export { CLIENT_API_VERSION, COLORS, @@ -192,4 +197,8 @@ export { LOCALE_TO_FLATPICKR_FORMAT_MAP, RESULTS_TABLE_PAGE_SIZES, TIME_TO_WAIT_BEFORE_CLOSING_TAG_EDIT_MODAL_MS, + DEFAULT_ACCESS_TOKEN_EXPIRY_WARNING_DAYS, + MAX_ACCESS_TOKEN_EXPIRY_WARNING_DAYS, + ACCESS_TOKEN_EXPIRY_WARNING_PROPERTY_NAME, + ACCESS_TOKEN_EXPIRY_WARNING_PROPERTY_NAMESPACE, }; From 5f118ed0e03132a5827d99e55b027a49b8104261 Mon Sep 17 00:00:00 2001 From: James Cocker Date: Thu, 30 Apr 2026 10:49:39 +0100 Subject: [PATCH 05/13] Added markdown error throwing Signed-off-by: James Cocker --- galasa-ui/src/app/page.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/galasa-ui/src/app/page.tsx b/galasa-ui/src/app/page.tsx index 03b119bd..d8ff75ff 100644 --- a/galasa-ui/src/app/page.tsx +++ b/galasa-ui/src/app/page.tsx @@ -41,6 +41,11 @@ export default async function HomePage() { }; } } + + // If no content was found in CPS, throw an error to trigger fallback + if (!content.markdownContent) { + throw new Error('No markdown content found in CPS property'); + } } catch (error: any) { console.warn('Failed to fetch custom markdown content from CPS', error); From dbb3c6c0e2ee88528e02dd7eb8cdbd1cde4e6b73 Mon Sep 17 00:00:00 2001 From: James Cocker Date: Thu, 30 Apr 2026 10:49:50 +0100 Subject: [PATCH 06/13] Formatting Signed-off-by: James Cocker --- galasa-ui/messages/de.json | 2 +- galasa-ui/messages/en.json | 2 +- galasa-ui/src/components/tokens/TokenCard.tsx | 8 +++----- .../tests/components/tokens/TokenRequestModal.test.tsx | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/galasa-ui/messages/de.json b/galasa-ui/messages/de.json index 317334b3..e6fb3f93 100644 --- a/galasa-ui/messages/de.json +++ b/galasa-ui/messages/de.json @@ -600,4 +600,4 @@ "deleteMessage": "Die Abfrage \"{name}\" wurde erfolgreich gelöscht.", "duplicateMessage": "Die Abfrage \"{name}\" wurde erfolgreich dupliziert." } -} \ No newline at end of file +} diff --git a/galasa-ui/messages/en.json b/galasa-ui/messages/en.json index 319b13a7..fb47fd43 100644 --- a/galasa-ui/messages/en.json +++ b/galasa-ui/messages/en.json @@ -579,4 +579,4 @@ "deleteMessage": "The query \"{name}\" was deleted successfully.", "duplicateMessage": "The query \"{name}\" was duplicated successfully." } -} \ No newline at end of file +} diff --git a/galasa-ui/src/components/tokens/TokenCard.tsx b/galasa-ui/src/components/tokens/TokenCard.tsx index 89712c4f..09915086 100644 --- a/galasa-ui/src/components/tokens/TokenCard.tsx +++ b/galasa-ui/src/components/tokens/TokenCard.tsx @@ -61,11 +61,11 @@ function TokenCard({
{translations('createdAt')} {formattedCreationDate}
- {formattedExpiryDate && + {formattedExpiryDate && (
{isExpired ? translations('expired') : translations('expires')} {formattedExpiryDate}
- } + )}
{translations('owner')} {token.owner?.loginId}
@@ -74,9 +74,7 @@ function TokenCard({
{isNearlyExpired && ( -

- {translations('nearlyExpiredWarning')} -

+

{translations('nearlyExpiredWarning')}

)}
diff --git a/galasa-ui/src/tests/components/tokens/TokenRequestModal.test.tsx b/galasa-ui/src/tests/components/tokens/TokenRequestModal.test.tsx index e5bbbcc3..10ea06a2 100644 --- a/galasa-ui/src/tests/components/tokens/TokenRequestModal.test.tsx +++ b/galasa-ui/src/tests/components/tokens/TokenRequestModal.test.tsx @@ -340,7 +340,7 @@ describe('Token request modal', () => { await act(async () => { render(); }); - + const openModalButtonElement = screen.getByRole('token-request-btn'); // When... From 3bcac21845dd5aa1a50fcc98c53f851731727594 Mon Sep 17 00:00:00 2001 From: James Cocker Date: Thu, 30 Apr 2026 10:58:24 +0100 Subject: [PATCH 07/13] Fix build process Signed-off-by: James Cocker --- galasa-ui/src/app/users/edit/page.tsx | 46 ++++++++++++++++++- .../__snapshots__/mysettings.test.tsx.snap | 2 + 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/galasa-ui/src/app/users/edit/page.tsx b/galasa-ui/src/app/users/edit/page.tsx index 6ffb4edd..9c013595 100644 --- a/galasa-ui/src/app/users/edit/page.tsx +++ b/galasa-ui/src/app/users/edit/page.tsx @@ -5,13 +5,14 @@ */ import PageTile from '@/components/PageTile'; import UserRoleSection from '@/components/users/UserRoleSection'; -import { RBACRole, RoleBasedAccessControlAPIApi } from '@/generated/galasaapi'; +import { ConfigurationPropertyStoreAPIApi, RBACRole, RoleBasedAccessControlAPIApi } from '@/generated/galasaapi'; import { createAuthenticatedApiConfiguration } from '@/utils/api'; import AccessTokensSection from '@/components/mysettings/AccessTokensSection'; import { fetchAccessTokens } from '@/actions/getUserAccessTokens'; import { fetchUserFromApiServer } from '@/actions/userServerActions'; import BreadCrumb from '@/components/common/BreadCrumb'; import { EDIT_USER, HOME } from '@/utils/constants/breadcrumb'; +import * as Constants from '@/utils/constants/common'; // In order to extract query param on server-side type UsersPageProps = { @@ -19,6 +20,29 @@ type UsersPageProps = { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }; +const getValidatedWarningDays = (value?: string) => { + const parsedValue = Number.parseInt(value ?? '', 10); + + if (Number.isNaN(parsedValue) || parsedValue < 0) { + return { + warningDays: Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_WARNING_DAYS, + exceededMaximum: false, + }; + } + + if (parsedValue > Constants.MAX_ACCESS_TOKEN_EXPIRY_WARNING_DAYS) { + return { + warningDays: Constants.MAX_ACCESS_TOKEN_EXPIRY_WARNING_DAYS, + exceededMaximum: true, + }; + } + + return { + warningDays: parsedValue, + exceededMaximum: false, + }; +}; + export default async function EditUserPage(props: UsersPageProps) { const searchParams = await props.searchParams; const loginIdFromQueryParam = searchParams.loginId as string; @@ -37,6 +61,24 @@ export default async function EditUserPage(props: UsersPageProps) { return roles; }; + + const fetchTokenExpiryWarningConfiguration = async () => { + try { + const cpsApiClientWithAuthHeader = new ConfigurationPropertyStoreAPIApi(apiConfig); + const warningPropertyResponse = await cpsApiClientWithAuthHeader.getCpsProperty( + Constants.ACCESS_TOKEN_EXPIRY_WARNING_PROPERTY_NAMESPACE, + Constants.ACCESS_TOKEN_EXPIRY_WARNING_PROPERTY_NAME + ); + + const warningPropertyValue = warningPropertyResponse?.[0]?.data?.value; + return getValidatedWarningDays(warningPropertyValue); + } catch (error) { + return getValidatedWarningDays(); + } + }; + + const tokenExpiryWarningConfiguration = await fetchTokenExpiryWarningConfiguration(); + return (
@@ -48,6 +90,8 @@ export default async function EditUserPage(props: UsersPageProps) {
); diff --git a/galasa-ui/src/tests/__snapshots__/mysettings.test.tsx.snap b/galasa-ui/src/tests/__snapshots__/mysettings.test.tsx.snap index 43e9b3cd..3a840751 100644 --- a/galasa-ui/src/tests/__snapshots__/mysettings.test.tsx.snap +++ b/galasa-ui/src/tests/__snapshots__/mysettings.test.tsx.snap @@ -23,6 +23,8 @@ exports[`MySettings Component renders correctly when user login id is found 1`] Date: Thu, 30 Apr 2026 11:34:41 +0100 Subject: [PATCH 08/13] Code reuse, formatting and more error messages Signed-off-by: James Cocker --- galasa-ui/src/app/mysettings/page.tsx | 1 + galasa-ui/src/app/users/edit/page.tsx | 6 +++++- galasa-ui/src/components/tokens/TokenCard.tsx | 6 +++--- galasa-ui/src/components/tokens/TokenRequestModal.tsx | 3 ++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/galasa-ui/src/app/mysettings/page.tsx b/galasa-ui/src/app/mysettings/page.tsx index 1c246b00..9238eaa8 100644 --- a/galasa-ui/src/app/mysettings/page.tsx +++ b/galasa-ui/src/app/mysettings/page.tsx @@ -83,6 +83,7 @@ export default async function MySettings() { const warningPropertyValue = warningPropertyResponse?.[0]?.data?.value; return getValidatedWarningDays(warningPropertyValue); } catch (error) { + console.error('Failed to fetch token expiry warning configuration, using defaults:', error); return getValidatedWarningDays(); } }; diff --git a/galasa-ui/src/app/users/edit/page.tsx b/galasa-ui/src/app/users/edit/page.tsx index 9c013595..69a86b74 100644 --- a/galasa-ui/src/app/users/edit/page.tsx +++ b/galasa-ui/src/app/users/edit/page.tsx @@ -5,7 +5,11 @@ */ import PageTile from '@/components/PageTile'; import UserRoleSection from '@/components/users/UserRoleSection'; -import { ConfigurationPropertyStoreAPIApi, RBACRole, RoleBasedAccessControlAPIApi } from '@/generated/galasaapi'; +import { + ConfigurationPropertyStoreAPIApi, + RBACRole, + RoleBasedAccessControlAPIApi, +} from '@/generated/galasaapi'; import { createAuthenticatedApiConfiguration } from '@/utils/api'; import AccessTokensSection from '@/components/mysettings/AccessTokensSection'; import { fetchAccessTokens } from '@/actions/getUserAccessTokens'; diff --git a/galasa-ui/src/components/tokens/TokenCard.tsx b/galasa-ui/src/components/tokens/TokenCard.tsx index 09915086..af29a632 100644 --- a/galasa-ui/src/components/tokens/TokenCard.tsx +++ b/galasa-ui/src/components/tokens/TokenCard.tsx @@ -12,6 +12,7 @@ import { AuthToken } from '@/generated/galasaapi'; import { useTranslations } from 'next-intl'; import { useDateTimeFormat } from '@/contexts/DateTimeFormatContext'; import { useMemo } from 'react'; +import { DAY_MS } from '@/utils/constants/common'; function TokenCard({ token, @@ -28,7 +29,7 @@ function TokenCard({ return formatDate(new Date(token.creationTime!)); }, [token.creationTime, formatDate]); const formattedExpiryDate = useMemo(() => { - return formatDate(new Date(token.expiryTime!)); + return token.expiryTime ? formatDate(new Date(token.expiryTime)) : null; }, [token.expiryTime, formatDate]); // Check if token is expired @@ -42,8 +43,7 @@ function TokenCard({ const expiryTime = new Date(token.expiryTime).getTime(); const currentTime = Date.now(); - // Where 86400000 = 24 hours in milliseconds = 24 * 60 * 60 * 1000 - const warningWindowInMilliseconds = expiryWarningDays * 86400000; + const warningWindowInMilliseconds = expiryWarningDays * DAY_MS; return expiryTime - currentTime <= warningWindowInMilliseconds; }, [token.expiryTime, isExpired, expiryWarningDays]); diff --git a/galasa-ui/src/components/tokens/TokenRequestModal.tsx b/galasa-ui/src/components/tokens/TokenRequestModal.tsx index 28a4130c..dc939d1c 100644 --- a/galasa-ui/src/components/tokens/TokenRequestModal.tsx +++ b/galasa-ui/src/components/tokens/TokenRequestModal.tsx @@ -56,7 +56,8 @@ export default function TokenRequestModal({ isDisabled }: { isDisabled: boolean const onChangeInputValidation = () => { const tokenName = tokenNameInputRef.current?.value.trim() ?? ''; const lifespan = getEffectiveLifespan(); - const isLifespanValid = lifespan >= MIN_LIFESPAN && lifespan <= MAX_LIFESPAN; + const isLifespanValid = + Number.isInteger(lifespan) && lifespan >= MIN_LIFESPAN && lifespan <= MAX_LIFESPAN; setSubmitDisabled(!tokenName || !isLifespanValid); }; From 0dd30d04d6269b5eee03f9c4af81b77731305394 Mon Sep 17 00:00:00 2001 From: James Cocker Date: Thu, 30 Apr 2026 11:40:38 +0100 Subject: [PATCH 09/13] Added unit tests Signed-off-by: James Cocker --- .../src/tests/app/mysettings/page.test.tsx | 126 ++++++++ .../mysettings/AccessTokenSection.test.tsx | 121 +++++++- .../components/tokens/TokenCard.test.tsx | 279 ++++++++++++++++++ .../tokens/TokenRequestModal.test.tsx | 116 ++++++++ 4 files changed, 636 insertions(+), 6 deletions(-) create mode 100644 galasa-ui/src/tests/app/mysettings/page.test.tsx create mode 100644 galasa-ui/src/tests/components/tokens/TokenCard.test.tsx diff --git a/galasa-ui/src/tests/app/mysettings/page.test.tsx b/galasa-ui/src/tests/app/mysettings/page.test.tsx new file mode 100644 index 00000000..131e2d4b --- /dev/null +++ b/galasa-ui/src/tests/app/mysettings/page.test.tsx @@ -0,0 +1,126 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + * @jest-environment node + */ + +// Test the getValidatedWarningDays function logic +describe('getValidatedWarningDays', () => { + const DEFAULT_WARNING_DAYS = 14; + const MAX_WARNING_DAYS = 30; + + // Simulate the function from mysettings/page.tsx + const getValidatedWarningDays = (value?: string) => { + const parsedValue = Number.parseInt(value ?? '', 10); + + if (Number.isNaN(parsedValue) || parsedValue < 0) { + return { + warningDays: DEFAULT_WARNING_DAYS, + exceededMaximum: false, + }; + } + + if (parsedValue > MAX_WARNING_DAYS) { + return { + warningDays: MAX_WARNING_DAYS, + exceededMaximum: true, + }; + } + + return { + warningDays: parsedValue, + exceededMaximum: false, + }; + }; + + it('returns default value when input is undefined', () => { + const result = getValidatedWarningDays(undefined); + expect(result.warningDays).toBe(DEFAULT_WARNING_DAYS); + expect(result.exceededMaximum).toBe(false); + }); + + it('returns default value when input is empty string', () => { + const result = getValidatedWarningDays(''); + expect(result.warningDays).toBe(DEFAULT_WARNING_DAYS); + expect(result.exceededMaximum).toBe(false); + }); + + it('returns default value when input is not a number', () => { + const result = getValidatedWarningDays('abc'); + expect(result.warningDays).toBe(DEFAULT_WARNING_DAYS); + expect(result.exceededMaximum).toBe(false); + }); + + it('returns default value when input is negative', () => { + const result = getValidatedWarningDays('-5'); + expect(result.warningDays).toBe(DEFAULT_WARNING_DAYS); + expect(result.exceededMaximum).toBe(false); + }); + + it('accepts zero as a valid value', () => { + const result = getValidatedWarningDays('0'); + expect(result.warningDays).toBe(0); + expect(result.exceededMaximum).toBe(false); + }); + + it('returns valid value when input is within range', () => { + const result = getValidatedWarningDays('20'); + expect(result.warningDays).toBe(20); + expect(result.exceededMaximum).toBe(false); + }); + + it('returns maximum value and sets exceededMaximum flag when input exceeds maximum', () => { + const result = getValidatedWarningDays('50'); + expect(result.warningDays).toBe(MAX_WARNING_DAYS); + expect(result.exceededMaximum).toBe(true); + }); + + it('returns maximum value and sets exceededMaximum flag when input is much larger than maximum', () => { + const result = getValidatedWarningDays('1000'); + expect(result.warningDays).toBe(MAX_WARNING_DAYS); + expect(result.exceededMaximum).toBe(true); + }); + + it('returns exact value when input equals maximum', () => { + const result = getValidatedWarningDays('30'); + expect(result.warningDays).toBe(MAX_WARNING_DAYS); + expect(result.exceededMaximum).toBe(false); + }); + + it('returns exact value when input equals minimum valid value (1)', () => { + const result = getValidatedWarningDays('1'); + expect(result.warningDays).toBe(1); + expect(result.exceededMaximum).toBe(false); + }); + + it('returns exact value when input equals default', () => { + const result = getValidatedWarningDays('14'); + expect(result.warningDays).toBe(DEFAULT_WARNING_DAYS); + expect(result.exceededMaximum).toBe(false); + }); + + it('handles decimal values by truncating to integer', () => { + const result = getValidatedWarningDays('15.7'); + expect(result.warningDays).toBe(15); + expect(result.exceededMaximum).toBe(false); + }); + + it('returns default value for special numeric strings like Infinity', () => { + const result = getValidatedWarningDays('Infinity'); + expect(result.warningDays).toBe(DEFAULT_WARNING_DAYS); + expect(result.exceededMaximum).toBe(false); + }); + + it('returns default value for whitespace-only input', () => { + const result = getValidatedWarningDays(' '); + expect(result.warningDays).toBe(DEFAULT_WARNING_DAYS); + expect(result.exceededMaximum).toBe(false); + }); + + it('handles numeric strings with leading/trailing whitespace', () => { + const result = getValidatedWarningDays(' 25 '); + expect(result.warningDays).toBe(25); + expect(result.exceededMaximum).toBe(false); + }); +}); diff --git a/galasa-ui/src/tests/components/mysettings/AccessTokenSection.test.tsx b/galasa-ui/src/tests/components/mysettings/AccessTokenSection.test.tsx index 4e733acc..8b31d478 100644 --- a/galasa-ui/src/tests/components/mysettings/AccessTokenSection.test.tsx +++ b/galasa-ui/src/tests/components/mysettings/AccessTokenSection.test.tsx @@ -15,6 +15,12 @@ jest.mock('@carbon/react', () => ({ {children} ), + InlineNotification: ({ title, subtitle, kind }: any) => ( +
+
{title}
+
{subtitle}
+
+ ), })); jest.mock('next-intl', () => ({ @@ -44,9 +50,10 @@ jest.mock('next-intl', () => ({ // Mock TokenCard to render a button that calls the passed callback jest.mock('@/components/tokens/TokenCard', () => { - const MockTokenCard = ({ token, selectTokenForDeletion }: any) => ( + const MockTokenCard = ({ token, selectTokenForDeletion, expiryWarningDays }: any) => (