diff --git a/galasa-ui/messages/de.json b/galasa-ui/messages/de.json index 51ca0b9e..74da8072 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", @@ -117,11 +119,20 @@ "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_expiry_date": "Benutzerdefiniertes Ablaufdatum", + "custom_expiry_date_helper_text": "Wählen Sie ein Datum zwischen 1 und 365 Tagen ab heute", + "custom_expiry_date_invalid": "Das Ablaufdatum muss zwischen 1 und 365 Tagen ab heute liegen", "error_requesting_token": "Fehler beim Anfordern des Tokens" }, "TokenCard": { "createdAt": "Erstellt am:", - "owner": "Inhaber" + "expires": "Läuft ab:", + "expired": "Abgelaufen:", + "owner": "Inhaber", + "nearlyExpiredWarning": "Ihr persönlicher Zugriffstoken läuft bald ab. Nach Ablauf des Tokens können Sie ihn nicht mehr verwenden, um diesen Galasa-Dienst zu kontaktieren." }, "TokenDeleteModal": { "modalHeading": "Zugriffstoken löschen", @@ -592,4 +603,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 3bdd1b0c..d9098208 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", @@ -117,11 +119,20 @@ "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" }, "TokenCard": { "createdAt": "Created at:", - "owner": "Owner" + "expires": "Expires:", + "expired": "Expired:", + "owner": "Owner", + "nearlyExpiredWarning": "Your personal access token will expire soon. Once it expires, you will no longer be able to use it to contact this Galasa service." }, "TokenDeleteModal": { "modalHeading": "Delete Access Tokens", diff --git a/galasa-ui/src/app/auth/tokens/route.ts b/galasa-ui/src/app/auth/tokens/route.ts index d6cb410b..82827c01 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; + tokenLifespanDays: 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.tokenLifespanDays), { + 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/app/mysettings/page.tsx b/galasa-ui/src/app/mysettings/page.tsx index c970007d..d710d463 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,8 @@ import { fetchUserFromApiServer } from '@/actions/userServerActions'; import ProfileRole from '@/components/users/UserRole'; import DateTimeSettings from '@/components/mysettings/DateTimeSettings'; import ResultsTablePageSizeSetting from '@/components/mysettings/ResultsTablePageSizeSetting'; +import { fetchTokenExpiryWarningConfiguration } from '@/utils/tokenExpiryWarning'; + export default async function MySettings() { const apiConfig = createAuthenticatedApiConfiguration(); @@ -55,6 +57,9 @@ export default async function MySettings() { return ; } + const cpsApiClient = new ConfigurationPropertyStoreAPIApi(apiConfig); + const tokenExpiryWarningConfiguration = await fetchTokenExpiryWarningConfiguration(cpsApiClient); + return (
@@ -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) => ( +
+
{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) => (