@@ -63,6 +68,8 @@ export default async function MySettings() {
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);
diff --git a/galasa-ui/src/app/users/edit/page.tsx b/galasa-ui/src/app/users/edit/page.tsx
index 6ffb4edd..039ff296 100644
--- a/galasa-ui/src/app/users/edit/page.tsx
+++ b/galasa-ui/src/app/users/edit/page.tsx
@@ -5,13 +5,18 @@
*/
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 { fetchTokenExpiryWarningConfiguration } from '@/utils/tokenExpiryWarning';
// In order to extract query param on server-side
type UsersPageProps = {
@@ -37,6 +42,10 @@ export default async function EditUserPage(props: UsersPageProps) {
return roles;
};
+
+ const cpsApiClient = new ConfigurationPropertyStoreAPIApi(apiConfig);
+ const tokenExpiryWarningConfiguration = await fetchTokenExpiryWarningConfiguration(cpsApiClient);
+
return (
@@ -48,6 +57,8 @@ export default async function EditUserPage(props: UsersPageProps) {
);
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 f46464ae..af29a632 100644
--- a/galasa-ui/src/components/tokens/TokenCard.tsx
+++ b/galasa-ui/src/components/tokens/TokenCard.tsx
@@ -12,39 +12,71 @@ 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,
selectTokenForDeletion,
+ expiryWarningDays,
}: {
token: AuthToken;
selectTokenForDeletion: Function;
+ expiryWarningDays: number;
}) {
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 token.expiryTime ? formatDate(new Date(token.expiryTime)) : null;
+ }, [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]);
+
+ const isNearlyExpired = useMemo(() => {
+ if (!token.expiryTime || isExpired) return false;
+
+ const expiryTime = new Date(token.expiryTime).getTime();
+ const currentTime = Date.now();
+ const warningWindowInMilliseconds = expiryWarningDays * DAY_MS;
+
+ return expiryTime - currentTime <= warningWindowInMilliseconds;
+ }, [token.expiryTime, isExpired, expiryWarningDays]);
return (
selectTokenForDeletion(token.tokenId)}
value={true}
key={token.tokenId}
- className={styles.cardContainer}
+ className={`${styles.cardContainer} ${isExpired ? styles.cardContainerExpired : ''}`}
>
{token.description}
- {translations('createdAt')} {formattedDate}
+ {translations('createdAt')} {formattedCreationDate}
+ {formattedExpiryDate && (
+
+ {isExpired ? translations('expired') : translations('expires')} {formattedExpiryDate}
+
+ )}
{translations('owner')} {token.owner?.loginId}
-
+
+
+ {isNearlyExpired && (
+
{translations('nearlyExpiredWarning')}
+ )}
+
);
}
diff --git a/galasa-ui/src/components/tokens/TokenRequestModal.tsx b/galasa-ui/src/components/tokens/TokenRequestModal.tsx
index ae3a1ef5..b9a86145 100644
--- a/galasa-ui/src/components/tokens/TokenRequestModal.tsx
+++ b/galasa-ui/src/components/tokens/TokenRequestModal.tsx
@@ -5,24 +5,88 @@
*/
'use client';
-import { Button, Modal } from '@carbon/react';
+import { Button, Modal, Dropdown, DatePicker, DatePickerInput } 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 { useDateTimeFormat } from '@/contexts/DateTimeFormatContext';
+import styles from '@/styles/tokens/TokenRequestModal.module.css';
+import {
+ TOKEN_PRESET_LIFESPANS,
+ TOKEN_CUSTOM_VALUE_ID,
+ TOKEN_CUSTOM_DEFAULT_LIFESPAN,
+ TOKEN_MIN_LIFESPAN,
+ TOKEN_MAX_LIFESPAN,
+ LOCALE_TO_FLATPICKR_FORMAT_MAP,
+ SUPPORTED_LOCALES,
+} from '@/utils/constants/common';
export default function TokenRequestModal({ isDisabled }: { isDisabled: boolean }) {
const translations = useTranslations('TokenRequestModal');
+ const { formatDate, preferences } = useDateTimeFormat();
const [open, setOpen] = useState(false);
const [error, setError] = useState('');
const [submitDisabled, setSubmitDisabled] = useState(true);
+ const [selectedLifespan, setSelectedLifespan] = useState(String(TOKEN_PRESET_LIFESPANS[0]));
+ const [customLifespan, setCustomLifespan] = useState(TOKEN_CUSTOM_DEFAULT_LIFESPAN);
+ const [customExpiryDate, setCustomExpiryDate] = useState(null);
const tokenNameInputRef = useRef(undefined);
+ // Helper function to get the effective locale based on dateTimeFormatType
+ const getEffectiveLocale = (): string => {
+ if (preferences.dateTimeFormatType === 'browser') {
+ // Get browser's locale
+ const browserLocale = navigator.language || 'en-GB';
+ // Check if it's a supported locale, otherwise default to en-GB
+ const supportedLocale = SUPPORTED_LOCALES.find(loc => loc.code === browserLocale);
+ return supportedLocale ? browserLocale : 'en-GB';
+ }
+ return preferences.locale;
+ };
+
+ const getExpiryDate = (days: number): string => {
+ const date = new Date();
+ date.setDate(date.getDate() + days);
+ return formatDate(date);
+ };
+
+ const getLifespanOptions = () => {
+ const options = TOKEN_PRESET_LIFESPANS.map((days) => ({
+ id: String(days),
+ label: `${days} days (${getExpiryDate(days)})`,
+ }));
+ options.push({
+ id: TOKEN_CUSTOM_VALUE_ID,
+ label: translations('custom_lifespan'),
+ });
+ return options;
+ };
+
+ const getEffectiveLifespan = (): number => {
+ if (selectedLifespan === TOKEN_CUSTOM_VALUE_ID) {
+ if (customExpiryDate) {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const expiry = new Date(customExpiryDate);
+ expiry.setHours(0, 0, 0, 0);
+ const diffTime = expiry.getTime() - today.getTime();
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+ return diffDays;
+ }
+ return customLifespan;
+ }
+ return parseInt(selectedLifespan, 10);
+ };
+
const onChangeInputValidation = () => {
const tokenName = tokenNameInputRef.current?.value.trim() ?? '';
- setSubmitDisabled(!tokenName);
+ const lifespan = getEffectiveLifespan();
+ const isLifespanValid =
+ Number.isInteger(lifespan) && lifespan >= TOKEN_MIN_LIFESPAN && lifespan <= TOKEN_MAX_LIFESPAN;
+ setSubmitDisabled(!tokenName || !isLifespanValid);
};
const submitTokenRequest = async () => {
@@ -31,6 +95,7 @@ export default function TokenRequestModal({ isDisabled }: { isDisabled: boolean
method: 'POST',
body: JSON.stringify({
tokenDescription: tokenNameInputRef.current?.value.trim(),
+ tokenLifespanDays: getEffectiveLifespan(),
}),
});
@@ -72,9 +137,13 @@ export default function TokenRequestModal({ isDisabled }: { isDisabled: boolean
secondaryButtonText={translations('cancel')}
shouldSubmitOnEnter={true}
open={open}
+ className={styles.modalContent}
onRequestClose={() => {
setOpen(false);
setError('');
+ setSelectedLifespan(String(TOKEN_PRESET_LIFESPANS[0]));
+ setCustomLifespan(TOKEN_CUSTOM_DEFAULT_LIFESPAN);
+ setCustomExpiryDate(null);
}}
onRequestSubmit={async () => {
if (!submitDisabled) {
@@ -99,6 +168,62 @@ 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 === TOKEN_CUSTOM_VALUE_ID && (
+ <>
+
+ {
+ if (dates && dates.length > 0) {
+ setCustomExpiryDate(dates[0]);
+ onChangeInputValidation();
+ }
+ }}
+ >
+ {
+ const dateFormat = LOCALE_TO_FLATPICKR_FORMAT_MAP[getEffectiveLocale()];
+ return dateFormat
+ .replace(/Y/g, 'yyyy')
+ .replace(/m/g, 'mm')
+ .replace(/d/g, 'dd');
+ })()}
+ labelText={translations('custom_expiry_date')}
+ helperText={translations('custom_expiry_date_helper_text')}
+ invalid={(() => {
+ if (!customExpiryDate) return false;
+ const days = getEffectiveLifespan();
+ return days < TOKEN_MIN_LIFESPAN || days > TOKEN_MAX_LIFESPAN;
+ })()}
+ invalidText={translations('custom_expiry_date_invalid')}
+ />
+
+ >
+ )}
+
{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/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 9e5ba7ca..a4ddcc58 100644
--- a/galasa-ui/src/styles/tokens/TokenCard.module.css
+++ b/galasa-ui/src/styles/tokens/TokenCard.module.css
@@ -9,10 +9,36 @@
width: 500px;
}
+.cardContainerExpired {
+ opacity: 0.6;
+ border-left: 4px solid var(--cds-support-error);
+}
+
.infoContainer {
- margin: 1rem 0;
+ flex: 1;
+ min-width: 0;
}
-.icon {
+.expiredLabel {
+ color: var(--cds-support-error);
+ font-weight: 600;
+}
+
+.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/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/__snapshots__/mysettings.test.tsx.snap b/galasa-ui/src/tests/__snapshots__/mysettings.test.tsx.snap
index 8b83c4f3..caf2d736 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`]
({
{children}
),
+ InlineNotification: ({ title, subtitle, kind }: any) => (
+
+ ),
}));
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) => (
selectTokenForDeletion(token.tokenId)}
>
TokenCard {token.tokenId}
@@ -81,7 +88,14 @@ jest.mock('@/components/tokens/TokenDeleteModal', () => {
describe('AccessTokensSection', () => {
test('displays loading indicator while fetching tokens', () => {
const pendingPromise = new Promise(() => {});
- render( );
+ render(
+
+ );
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
@@ -91,7 +105,14 @@ describe('AccessTokensSection', () => {
tokens: [{ tokenId: 'token-1' }, { tokenId: 'token-2' }],
};
const resolvedPromise = Promise.resolve(authTokens);
- render( );
+ render(
+
+ );
// Wait for the heading (which only renders after loading is finished)
await waitFor(() => expect(screen.getByText('Access Tokens')).toBeInTheDocument());
@@ -115,7 +136,14 @@ describe('AccessTokensSection', () => {
test('renders error page when fetching tokens fails', async () => {
const rejectedPromise = Promise.reject(new Error('Fetch error'));
- render( );
+ render(
+
+ );
// Wait for the error page to be rendered.
await waitFor(() => expect(screen.getByText('Something went wrong!')).toBeInTheDocument());
@@ -126,7 +154,14 @@ describe('AccessTokensSection', () => {
tokens: [{ tokenId: 'token-1' }],
};
const resolvedPromise = Promise.resolve(authTokens);
- render( );
+ render(
+
+ );
// Wait for the token to render.
await waitFor(() => expect(screen.getByTestId('token-card-token-1')).toBeInTheDocument());
@@ -159,7 +194,14 @@ describe('AccessTokensSection', () => {
tokens: [{ tokenId: 'token-1' }],
};
const resolvedPromise = Promise.resolve(authTokens);
- render( );
+ render(
+
+ );
// Wait for the token to appear.
await waitFor(() => expect(screen.getByTestId('token-card-token-1')).toBeInTheDocument());
@@ -175,4 +217,71 @@ describe('AccessTokensSection', () => {
expect(screen.getByTestId('token-request-modal')).toHaveTextContent('Disabled')
);
});
+
+ test('passes tokenExpiryWarningDays prop to TokenCard components', async () => {
+ const authTokens: AuthTokens = {
+ tokens: [{ tokenId: 'token-1' }, { tokenId: 'token-2' }],
+ };
+ const resolvedPromise = Promise.resolve(authTokens);
+ const warningDays = 21;
+
+ render(
+
+ );
+
+ await waitFor(() => expect(screen.getByTestId('token-card-token-1')).toBeInTheDocument());
+
+ const tokenCard1 = screen.getByTestId('token-card-token-1');
+ const tokenCard2 = screen.getByTestId('token-card-token-2');
+
+ expect(tokenCard1).toHaveAttribute('data-expiry-warning-days', String(warningDays));
+ expect(tokenCard2).toHaveAttribute('data-expiry-warning-days', String(warningDays));
+ });
+
+ test('displays warning notification when showMaxWarningDaysNotice is true', async () => {
+ const authTokens: AuthTokens = {
+ tokens: [{ tokenId: 'token-1' }],
+ };
+ const resolvedPromise = Promise.resolve(authTokens);
+
+ render(
+
+ );
+
+ await waitFor(() => expect(screen.getByText('Access Tokens')).toBeInTheDocument());
+
+ const notification = screen.getByTestId('inline-notification');
+ expect(notification).toBeInTheDocument();
+ expect(notification).toHaveAttribute('data-kind', 'warning');
+ });
+
+ test('does not display warning notification when showMaxWarningDaysNotice is false', async () => {
+ const authTokens: AuthTokens = {
+ tokens: [{ tokenId: 'token-1' }],
+ };
+ const resolvedPromise = Promise.resolve(authTokens);
+
+ render(
+
+ );
+
+ await waitFor(() => expect(screen.getByText('Access Tokens')).toBeInTheDocument());
+
+ expect(screen.queryByTestId('inline-notification')).not.toBeInTheDocument();
+ });
});
diff --git a/galasa-ui/src/tests/components/tokens/TokenCard.test.tsx b/galasa-ui/src/tests/components/tokens/TokenCard.test.tsx
new file mode 100644
index 00000000..c5755c11
--- /dev/null
+++ b/galasa-ui/src/tests/components/tokens/TokenCard.test.tsx
@@ -0,0 +1,279 @@
+/*
+ * Copyright contributors to the Galasa project
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+import { render, screen, act } from '@testing-library/react';
+import TokenCard from '@/components/tokens/TokenCard';
+import { AuthToken } from '@/generated/galasaapi';
+
+// Mock next-intl
+jest.mock('next-intl', () => ({
+ useTranslations: () => (key: string) => {
+ const translations: Record = {
+ createdAt: 'Created at',
+ expires: 'Expires',
+ expired: 'Expired',
+ owner: 'Owner',
+ nearlyExpiredWarning: 'This token is expiring soon',
+ };
+ return translations[key] || key;
+ },
+}));
+
+// Mock DateTimeFormatContext
+jest.mock('@/contexts/DateTimeFormatContext', () => ({
+ useDateTimeFormat: () => ({
+ formatDate: (date: Date) => date.toLocaleDateString('en-GB'),
+ }),
+}));
+
+describe('TokenCard', () => {
+ const mockSelectTokenForDeletion = jest.fn();
+ const baseToken: AuthToken = {
+ tokenId: 'token-123',
+ description: 'Test Token',
+ creationTime: '2024-01-01T00:00:00Z',
+ owner: {
+ loginId: 'testuser',
+ },
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders token card with basic information', () => {
+ const token: AuthToken = {
+ ...baseToken,
+ expiryTime: '2024-12-31T23:59:59Z',
+ };
+
+ render(
+
+ );
+
+ expect(screen.getByText('Test Token')).toBeInTheDocument();
+ expect(screen.getByText(/Created at/)).toBeInTheDocument();
+ expect(screen.getByText(/Owner testuser/)).toBeInTheDocument();
+ });
+
+ it('renders token without expiry time', () => {
+ const token: AuthToken = {
+ ...baseToken,
+ expiryTime: undefined,
+ };
+
+ render(
+
+ );
+
+ expect(screen.getByText('Test Token')).toBeInTheDocument();
+ expect(screen.queryByText(/Expires/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/Expired/)).not.toBeInTheDocument();
+ });
+
+ it('shows expired status for expired tokens', () => {
+ const pastDate = new Date();
+ pastDate.setDate(pastDate.getDate() - 10);
+
+ const token: AuthToken = {
+ ...baseToken,
+ expiryTime: pastDate.toISOString(),
+ };
+
+ render(
+
+ );
+
+ expect(screen.getByText(/Expired/)).toBeInTheDocument();
+ expect(screen.queryByText(/Expires/)).not.toBeInTheDocument();
+ });
+
+ it('shows expires status for non-expired tokens', () => {
+ const futureDate = new Date();
+ futureDate.setDate(futureDate.getDate() + 30);
+
+ const token: AuthToken = {
+ ...baseToken,
+ expiryTime: futureDate.toISOString(),
+ };
+
+ render(
+
+ );
+
+ expect(screen.getByText(/Expires/)).toBeInTheDocument();
+ expect(screen.queryByText(/Expired/)).not.toBeInTheDocument();
+ });
+
+ it('shows warning for tokens expiring within warning threshold', () => {
+ const nearFutureDate = new Date();
+ nearFutureDate.setDate(nearFutureDate.getDate() + 7); // 7 days from now
+
+ const token: AuthToken = {
+ ...baseToken,
+ expiryTime: nearFutureDate.toISOString(),
+ };
+
+ render(
+
+ );
+
+ expect(screen.getByText('This token is expiring soon')).toBeInTheDocument();
+ });
+
+ it('does not show warning for tokens expiring beyond warning threshold', () => {
+ const farFutureDate = new Date();
+ farFutureDate.setDate(farFutureDate.getDate() + 30); // 30 days from now
+
+ const token: AuthToken = {
+ ...baseToken,
+ expiryTime: farFutureDate.toISOString(),
+ };
+
+ render(
+
+ );
+
+ expect(screen.queryByText('This token is expiring soon')).not.toBeInTheDocument();
+ });
+
+ it('does not show warning for expired tokens', () => {
+ const pastDate = new Date();
+ pastDate.setDate(pastDate.getDate() - 5);
+
+ const token: AuthToken = {
+ ...baseToken,
+ expiryTime: pastDate.toISOString(),
+ };
+
+ render(
+
+ );
+
+ expect(screen.queryByText('This token is expiring soon')).not.toBeInTheDocument();
+ });
+
+ it('shows warning for token expiring exactly at warning threshold', () => {
+ const exactThresholdDate = new Date();
+ exactThresholdDate.setDate(exactThresholdDate.getDate() + 14); // Exactly 14 days
+
+ const token: AuthToken = {
+ ...baseToken,
+ expiryTime: exactThresholdDate.toISOString(),
+ };
+
+ render(
+
+ );
+
+ expect(screen.getByText('This token is expiring soon')).toBeInTheDocument();
+ });
+
+ it('applies expired styling to expired tokens', () => {
+ const pastDate = new Date();
+ pastDate.setDate(pastDate.getDate() - 10);
+
+ const token: AuthToken = {
+ ...baseToken,
+ expiryTime: pastDate.toISOString(),
+ };
+
+ const { container } = render(
+
+ );
+
+ const tileElement = container.querySelector('.cardContainerExpired');
+ expect(tileElement).toBeInTheDocument();
+ });
+
+ it('calls selectTokenForDeletion when clicked', () => {
+ const token: AuthToken = {
+ ...baseToken,
+ expiryTime: '2024-12-31T23:59:59Z',
+ };
+
+ render(
+
+ );
+
+ const tileElement = screen.getByText('Test Token').closest('.cardContainer');
+ if (tileElement) {
+ act(() => {
+ (tileElement as HTMLElement).click();
+ });
+ expect(mockSelectTokenForDeletion).toHaveBeenCalledWith('token-123');
+ }
+ });
+
+ it('handles different warning day thresholds correctly', () => {
+ const futureDate = new Date();
+ futureDate.setDate(futureDate.getDate() + 25); // 25 days from now
+
+ const token: AuthToken = {
+ ...baseToken,
+ expiryTime: futureDate.toISOString(),
+ };
+
+ // With 14 day threshold, should not show warning
+ const { rerender } = render(
+
+ );
+ expect(screen.queryByText('This token is expiring soon')).not.toBeInTheDocument();
+
+ // With 30 day threshold, should show warning
+ rerender(
+
+ );
+ expect(screen.getByText('This token is expiring soon')).toBeInTheDocument();
+ });
+});
diff --git a/galasa-ui/src/tests/components/tokens/TokenRequestModal.test.tsx b/galasa-ui/src/tests/components/tokens/TokenRequestModal.test.tsx
index b67b7ffe..adfb4eec 100644
--- a/galasa-ui/src/tests/components/tokens/TokenRequestModal.test.tsx
+++ b/galasa-ui/src/tests/components/tokens/TokenRequestModal.test.tsx
@@ -5,6 +5,7 @@
*/
import TokenRequestModal from '@/components/tokens/TokenRequestModal';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { DateTimeFormatProvider } from '@/contexts/DateTimeFormatContext';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
@@ -19,12 +20,23 @@ 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_expiry_date: 'Custom expiry date',
+ custom_expiry_date_helper_text: 'Select a date between 1 and 365 days from today',
+ custom_expiry_date_invalid: 'Expiry date must be between 1 and 365 days from today',
error_requesting_token: 'Error requesting access token',
};
return translations[key] || key;
},
}));
+// Helper function to render TokenRequestModal with required providers
+const renderWithProviders = (component: React.ReactElement) => {
+ return render({component} );
+};
+
afterEach(() => {
jest.clearAllMocks();
});
@@ -37,7 +49,7 @@ describe('Token request modal', () => {
it('renders invisible token request modal', async () => {
// Given...
await act(async () => {
- return render( );
+ return renderWithProviders( );
});
const buttonElement = screen.getByRole('token-request-btn');
const requestModalElement = screen.getByRole('presentation');
@@ -51,7 +63,7 @@ describe('Token request modal', () => {
it('becomes visible when the "Request Access Token" button is clicked', async () => {
// Given...
await act(async () => {
- return render( );
+ return renderWithProviders( );
});
const buttonElement = screen.getByRole('token-request-btn');
const requestModalElement = screen.getByRole('presentation');
@@ -66,7 +78,7 @@ describe('Token request modal', () => {
it('becomes invisible when the "Cancel" button is clicked', async () => {
// Given...
await act(async () => {
- return render( );
+ return renderWithProviders( );
});
const openModalButtonElement = screen.getByRole('token-request-btn');
const modalCancelButtonElement = screen.getByText(/Cancel/i);
@@ -94,7 +106,7 @@ describe('Token request modal', () => {
) as jest.Mock;
await act(async () => {
- return render( );
+ return renderWithProviders( );
});
const openModalButtonElement = screen.getByRole('token-request-btn');
const modalCreateButtonElement = screen.getByText(/^Create$/);
@@ -111,6 +123,7 @@ describe('Token request modal', () => {
method: 'POST',
body: JSON.stringify({
tokenDescription: 'dummy',
+ tokenLifespanDays: 7,
}),
});
});
@@ -127,7 +140,7 @@ describe('Token request modal', () => {
) as jest.Mock;
await act(async () => {
- return render( );
+ return renderWithProviders( );
});
const openModalButtonElement = screen.getByRole('token-request-btn');
const modalSubmitButtonElement = screen.getByText(/^Create$/);
@@ -159,7 +172,7 @@ describe('Token request modal', () => {
global.fetch = jest.fn(() => Promise.reject(fetchErrorMessage)) as jest.Mock;
await act(async () => {
- render( );
+ renderWithProviders( );
});
const openModalButtonElement = screen.getByRole('token-request-btn');
const modalSubmitButtonElement = screen.getByText(/^Create$/);
@@ -191,7 +204,7 @@ describe('Token request modal', () => {
global.fetch = jest.fn(() => Promise.reject(fetchErrorMessage)) as jest.Mock;
await act(async () => {
- render( );
+ renderWithProviders( );
});
const openModalButtonElement = screen.getByRole('token-request-btn');
const modalSubmitButtonElement = screen.getByText(/^Create$/);
@@ -231,7 +244,7 @@ describe('Token request modal', () => {
) as jest.Mock;
await act(async () => {
- render( );
+ renderWithProviders( );
});
const openModalButtonElement = screen.getByRole('token-request-btn');
@@ -258,7 +271,7 @@ describe('Token request modal', () => {
) as jest.Mock;
await act(async () => {
- render( );
+ renderWithProviders( );
});
const openModalButtonElement = screen.getByRole('token-request-btn');
@@ -286,7 +299,7 @@ describe('Token request modal', () => {
) as jest.Mock;
await act(async () => {
- return render( );
+ return renderWithProviders( );
});
const openModalButtonElement = screen.getByRole('token-request-btn');
const modalNameInputElement = screen.getByLabelText(/Token Name/i);
@@ -302,6 +315,7 @@ describe('Token request modal', () => {
method: 'POST',
body: JSON.stringify({
tokenDescription: 'dummy',
+ tokenLifespanDays: 7,
}),
});
});
@@ -317,7 +331,7 @@ describe('Token request modal', () => {
) as jest.Mock;
await act(async () => {
- render( );
+ renderWithProviders( );
});
const openModalButtonElement = screen.getByRole('token-request-btn');
@@ -332,4 +346,153 @@ describe('Token request modal', () => {
// Then...
expect(global.fetch).not.toHaveBeenCalled();
});
+
+ it('renders token lifespan dropdown with default value', async () => {
+ // Given...
+ await act(async () => {
+ renderWithProviders( );
+ });
+
+ const openModalButtonElement = screen.getByRole('token-request-btn');
+
+ // When...
+ fireEvent.click(openModalButtonElement);
+
+ // Then...
+ const lifespanDropdown = screen.getByText(/Token lifespan/i);
+ expect(lifespanDropdown).toBeInTheDocument();
+ });
+
+ it('includes tokenLifespanDays 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 () => {
+ renderWithProviders( );
+ });
+
+ 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',
+ tokenLifespanDays: 7,
+ }),
+ })
+ );
+ });
+
+ it('shows custom expiry date picker when Custom is selected', async () => {
+ // Given...
+ await act(async () => {
+ renderWithProviders( );
+ });
+
+ 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 expiry date/i);
+ expect(customInput).toBeInTheDocument();
+ });
+ });
+
+ it('includes custom token_lifespan_days in POST request based on selected date', async () => {
+ // Given...
+ const redirectUrl = 'http://my-connector/auth';
+ const customDays = 45;
+ const futureDate = new Date();
+ futureDate.setDate(futureDate.getDate() + customDays);
+
+ global.fetch = jest.fn(() =>
+ Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ url: redirectUrl }),
+ })
+ ) as jest.Mock;
+ await act(async () => {
+ renderWithProviders( );
+ });
+ 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);
+
+ // Select a date (simulating date picker selection)
+ await waitFor(() => {
+ const customInput = screen.getByLabelText(/Custom expiry date/i);
+ expect(customInput).toBeInTheDocument();
+ });
+
+ // Simulate the DatePicker onChange by directly calling the component's state setter
+ // Note: In a real scenario, you'd interact with the flatpickr calendar
+ const modalCreateButtonElement = screen.getByText(/^Create$/);
+
+ // We can't easily test the exact date selection with flatpickr in jsdom,
+ // so we'll just verify the date picker is present
+ expect(screen.getByLabelText(/Custom expiry date/i)).toBeInTheDocument();
+ });
+
+ it('shows date picker with min and max date constraints', async () => {
+ // Given...
+ await act(async () => {
+ renderWithProviders( );
+ });
+
+ const openModalButtonElement = screen.getByRole('token-request-btn');
+
+ // When...
+ fireEvent.click(openModalButtonElement);
+
+ // Select Custom from dropdown
+ const dropdownButton = screen.getByRole('combobox');
+ fireEvent.click(dropdownButton);
+ const customOption = screen.getByText(/^Custom$/);
+ fireEvent.click(customOption);
+
+ // Then...
+ await waitFor(() => {
+ const customInput = screen.getByLabelText(/Custom expiry date/i);
+ expect(customInput).toBeInTheDocument();
+ // The DatePicker component handles min/max date validation internally
+ });
+ });
});
diff --git a/galasa-ui/src/tests/components/users/EditUserPage.test.tsx b/galasa-ui/src/tests/components/users/EditUserPage.test.tsx
index ff0fa972..b434f870 100644
--- a/galasa-ui/src/tests/components/users/EditUserPage.test.tsx
+++ b/galasa-ui/src/tests/components/users/EditUserPage.test.tsx
@@ -67,6 +67,11 @@ jest.mock('@/generated/galasaapi', () => ({
{ id: '2', name: 'User', description: 'Regular user role' },
]),
})),
+ ConfigurationPropertyStoreAPIApi: jest.fn().mockImplementation(() => ({
+ getCpsProperty: jest.fn().mockResolvedValue([
+ { data: { value: '14' } },
+ ]),
+ })),
RBACRole: {},
}));
diff --git a/galasa-ui/src/tests/mysettings.test.tsx b/galasa-ui/src/tests/mysettings.test.tsx
index ac2c9347..e03df45b 100644
--- a/galasa-ui/src/tests/mysettings.test.tsx
+++ b/galasa-ui/src/tests/mysettings.test.tsx
@@ -24,6 +24,11 @@ jest.mock('next/headers', () => ({
jest.mock('@/generated/galasaapi', () => {
return {
UsersAPIApi: jest.fn(),
+ ConfigurationPropertyStoreAPIApi: jest.fn().mockImplementation(() => ({
+ getCpsProperty: jest.fn().mockResolvedValue([
+ { data: { value: '14' } },
+ ]),
+ })),
};
});
diff --git a/galasa-ui/src/tests/routes/authTokens.test.ts b/galasa-ui/src/tests/routes/authTokens.test.ts
index 3f681317..4ac4fecf 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',
+ tokenLifespanDays: 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',
+ tokenLifespanDays: 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',
+ tokenLifespanDays: 7,
});
const request = new NextRequest('https://my-server/auth/tokens', {
diff --git a/galasa-ui/src/tests/utils/tokenExpiryWarning.test.ts b/galasa-ui/src/tests/utils/tokenExpiryWarning.test.ts
new file mode 100644
index 00000000..fad8b1c6
--- /dev/null
+++ b/galasa-ui/src/tests/utils/tokenExpiryWarning.test.ts
@@ -0,0 +1,127 @@
+/*
+ * Copyright contributors to the Galasa project
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+import { getValidatedWarningDays, fetchTokenExpiryWarningConfiguration } from '@/utils/tokenExpiryWarning';
+import { ConfigurationPropertyStoreAPIApi } from '@/generated/galasaapi';
+import * as Constants from '@/utils/constants/common';
+
+describe('tokenExpiryWarning utilities', () => {
+ describe('getValidatedWarningDays', () => {
+ const DEFAULT_WARNING_DAYS = Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_WARNING_DAYS;
+ const MAX_WARNING_DAYS = Constants.MAX_ACCESS_TOKEN_EXPIRY_WARNING_DAYS;
+
+ 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 exact value when input equals maximum', () => {
+ const result = getValidatedWarningDays(String(MAX_WARNING_DAYS));
+ expect(result.warningDays).toBe(MAX_WARNING_DAYS);
+ expect(result.exceededMaximum).toBe(false);
+ });
+ });
+
+ describe('fetchTokenExpiryWarningConfiguration', () => {
+ let mockCpsApiClient: jest.Mocked;
+
+ beforeEach(() => {
+ mockCpsApiClient = {
+ getCpsProperty: jest.fn(),
+ } as any;
+ });
+
+ it('returns validated warning days from API response', async () => {
+ mockCpsApiClient.getCpsProperty.mockResolvedValue([
+ { data: { value: '25' } },
+ ] as any);
+
+ const result = await fetchTokenExpiryWarningConfiguration(mockCpsApiClient);
+
+ expect(result.warningDays).toBe(25);
+ expect(result.exceededMaximum).toBe(false);
+ expect(mockCpsApiClient.getCpsProperty).toHaveBeenCalledWith(
+ Constants.ACCESS_TOKEN_EXPIRY_WARNING_PROPERTY_NAMESPACE,
+ Constants.ACCESS_TOKEN_EXPIRY_WARNING_PROPERTY_NAME
+ );
+ });
+
+ it('returns default value when API returns invalid data', async () => {
+ mockCpsApiClient.getCpsProperty.mockResolvedValue([
+ { data: { value: 'invalid' } },
+ ] as any);
+
+ const result = await fetchTokenExpiryWarningConfiguration(mockCpsApiClient);
+
+ expect(result.warningDays).toBe(Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_WARNING_DAYS);
+ expect(result.exceededMaximum).toBe(false);
+ });
+
+ it('returns default value when API call fails', async () => {
+ mockCpsApiClient.getCpsProperty.mockRejectedValue(new Error('API Error'));
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
+
+ const result = await fetchTokenExpiryWarningConfiguration(mockCpsApiClient);
+
+ expect(result.warningDays).toBe(Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_WARNING_DAYS);
+ expect(result.exceededMaximum).toBe(false);
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to fetch token expiry warning configuration, using defaults:',
+ expect.any(Error)
+ );
+
+ consoleSpy.mockRestore();
+ });
+
+ it('caps value at maximum and sets exceededMaximum flag', async () => {
+ mockCpsApiClient.getCpsProperty.mockResolvedValue([
+ { data: { value: '100' } },
+ ] as any);
+
+ const result = await fetchTokenExpiryWarningConfiguration(mockCpsApiClient);
+
+ expect(result.warningDays).toBe(Constants.MAX_ACCESS_TOKEN_EXPIRY_WARNING_DAYS);
+ expect(result.exceededMaximum).toBe(true);
+ });
+ });
+});
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',
};
diff --git a/galasa-ui/src/utils/constants/common.ts b/galasa-ui/src/utils/constants/common.ts
index 2b963566..e339a3e7 100644
--- a/galasa-ui/src/utils/constants/common.ts
+++ b/galasa-ui/src/utils/constants/common.ts
@@ -181,6 +181,17 @@ 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';
+
+const TOKEN_PRESET_LIFESPANS = [7, 30, 90];
+const TOKEN_CUSTOM_VALUE_ID = 'custom';
+const TOKEN_CUSTOM_DEFAULT_LIFESPAN = 90;
+const TOKEN_MIN_LIFESPAN = 1;
+const TOKEN_MAX_LIFESPAN = 365;
+
export {
CLIENT_API_VERSION,
COLORS,
@@ -207,4 +218,13 @@ 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,
+ TOKEN_PRESET_LIFESPANS,
+ TOKEN_CUSTOM_VALUE_ID,
+ TOKEN_MIN_LIFESPAN,
+ TOKEN_MAX_LIFESPAN,
+ TOKEN_CUSTOM_DEFAULT_LIFESPAN,
};
diff --git a/galasa-ui/src/utils/tokenExpiryWarning.ts b/galasa-ui/src/utils/tokenExpiryWarning.ts
new file mode 100644
index 00000000..2a9564dc
--- /dev/null
+++ b/galasa-ui/src/utils/tokenExpiryWarning.ts
@@ -0,0 +1,66 @@
+/*
+ * Copyright contributors to the Galasa project
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+import { ConfigurationPropertyStoreAPIApi } from '@/generated/galasaapi';
+import * as Constants from '@/utils/constants/common';
+
+export interface TokenExpiryWarningResult {
+ warningDays: number;
+ exceededMaximum: boolean;
+}
+
+/**
+ * Validates and normalizes the token expiry warning days value.
+ * Returns default value if input is invalid, maximum value if exceeded, or the parsed value otherwise.
+ *
+ * @param value - The string value to validate and parse
+ * @returns An object containing the validated warning days and whether the maximum was exceeded
+ */
+export const getValidatedWarningDays = (value?: string): TokenExpiryWarningResult => {
+ 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,
+ };
+};
+
+/**
+ * Fetches the token expiry warning configuration from the CPS API.
+ * Falls back to default values if the fetch fails or returns invalid data.
+ *
+ * @param cpsApiClient - The ConfigurationPropertyStoreAPIApi client instance
+ * @returns A promise resolving to the validated token expiry warning configuration
+ */
+export const fetchTokenExpiryWarningConfiguration = async (
+ cpsApiClient: ConfigurationPropertyStoreAPIApi
+): Promise => {
+ try {
+ const warningPropertyResponse = await cpsApiClient.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) {
+ console.error('Failed to fetch token expiry warning configuration, using defaults:', error);
+ return getValidatedWarningDays();
+ }
+};