diff --git a/packages/console/src/components/DateTime/index.tsx b/packages/console/src/components/DateTime/index.tsx index c40b39bfd..255adf090 100644 --- a/packages/console/src/components/DateTime/index.tsx +++ b/packages/console/src/components/DateTime/index.tsx @@ -1,5 +1,5 @@ import { type Nullable } from '@silverhand/essentials'; -import { isValid } from 'date-fns'; +import { isValid, format as formatDate } from 'date-fns'; const parseDate = (date: Nullable) => { if (!date) { @@ -12,19 +12,49 @@ const parseDate = (date: Nullable) => { type Props = { readonly children: Nullable; + readonly format?: string; }; +const defaultDateFormat = 'yyyy-MM-dd'; +const defaultDateTimeFormat = 'yyyy-MM-dd HH:mm:ss'; + /** * Safely display a date in the user's locale. If the date is invalid, it will display a dash. + * @param format - Optional date-fns format string to customize the date format */ -export function LocaleDate({ children }: Props) { - return {parseDate(children)?.toLocaleDateString() ?? '-'}; +export function LocaleDate({ children, format = defaultDateFormat }: Props) { + const date = parseDate(children); + + if (!date) { + return -; + } + + return {formatDate(date, format)}; } /** - * Safely display a date and time in the user's locale. If the date is invalid, it will display a - * dash. + * Safely display a date and time. If the date is invalid, it will display a dash. + * + * @param format - Optional date-fns format string to customize the date and time format + * + * @example + * // Default format + * {new Date()} + * // Custom format + * + * {new Date()} + * + * + * The `format` parameter accepts any valid date-fns format string. If not provided, + * it uses the default format which includes year, month, day, hour, minute, and second. + * For format string reference, see: https://date-fns.org/docs/format */ -export function LocaleDateTime({ children }: Props) { - return {parseDate(children)?.toLocaleString() ?? '-'}; +export function LocaleDateTime({ children, format = defaultDateTimeFormat }: Props) { + const date = parseDate(children); + + if (!date) { + return -; + } + + return {formatDate(date, format)}; } diff --git a/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/CertificateActionMenu/index.module.scss b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/CertificateActionMenu/index.module.scss index ffa4d6683..58c24bb61 100644 --- a/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/CertificateActionMenu/index.module.scss +++ b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/CertificateActionMenu/index.module.scss @@ -1,3 +1,8 @@ .icon { color: var(--color-text-secondary); + + > svg { + width: 20px; + height: 20px; + } } diff --git a/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/CertificateActionMenu/index.tsx b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/CertificateActionMenu/index.tsx index 4368c51fb..1606ecbc5 100644 --- a/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/CertificateActionMenu/index.tsx +++ b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/CertificateActionMenu/index.tsx @@ -2,8 +2,11 @@ import { type SamlApplicationSecretResponse } from '@logto/schemas'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import Delete from '@/assets/icons/delete.svg?react'; import Download from '@/assets/icons/download.svg?react'; +import Deactivate from '@/assets/icons/moon.svg?react'; import More from '@/assets/icons/more.svg?react'; +import Activate from '@/assets/icons/sun.svg?react'; import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu'; import { downloadText } from '@/utils/downloader'; @@ -11,12 +14,21 @@ import { buildSamlSigningCertificateFilename } from '../utils'; import styles from './index.module.scss'; -type Props = { +export type Props = { readonly appId: string; readonly secret: SamlApplicationSecretResponse; + readonly onDelete: (id: string) => void; + readonly onActivate: (id: string) => void; + readonly onDeactivate: (id: string) => void; }; -function CertificateActionMenu({ secret: { id, certificate }, appId }: Props) { +function CertificateActionMenu({ + secret: { id, certificate, active }, + appId, + onDelete, + onActivate, + onDeactivate, +}: Props) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const onDownload = useCallback(() => { @@ -29,6 +41,39 @@ function CertificateActionMenu({ secret: { id, certificate }, appId }: Props) { return ( } title={t('general.more_options')}> + {active ? ( + } + onClick={() => { + onDeactivate(id); + }} + > + {t('general.deactivate')} + + ) : ( + <> + {/* Can only delete inactive certificates */} + } + onClick={() => { + onDelete(id); + }} + > + {t('general.delete')} + + } + onClick={() => { + onActivate(id); + }} + > + {t('general.activate')} + + + )} } onClick={onDownload}> {t('general.download')} diff --git a/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/CreateSecretModal.tsx b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/CreateSecretModal.tsx new file mode 100644 index 000000000..368aedfc6 --- /dev/null +++ b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/CreateSecretModal.tsx @@ -0,0 +1,128 @@ +import { type SamlApplicationSecret } from '@logto/schemas'; +import { addMilliseconds, addYears, format } from 'date-fns'; +import { useCallback, useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { toast } from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; +import ReactModal from 'react-modal'; + +import Button from '@/ds-components/Button'; +import DangerousRaw from '@/ds-components/DangerousRaw'; +import FormField from '@/ds-components/FormField'; +import ModalLayout from '@/ds-components/ModalLayout'; +import Select from '@/ds-components/Select'; +import useApi from '@/hooks/use-api'; +import modalStyles from '@/scss/modal.module.scss'; +import { trySubmitSafe } from '@/utils/form'; + +type FormData = { lifeSpanInYears: string }; + +type Props = { + readonly appId: string; + readonly isOpen: boolean; + readonly onClose: (createdSamlAppSecret?: SamlApplicationSecret) => void; +}; + +const years = Object.freeze([1, 3, 5, 10]); + +// Update expiration date every minute +const intervalExpirationRefresh = 60 * 1000; // In milliseconds. + +function CreateSecretModal({ appId, isOpen, onClose }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { + control, + watch, + formState: { isSubmitting }, + handleSubmit, + reset, + } = useForm({ defaultValues: { lifeSpanInYears: '1' } }); + const onCloseHandler = useCallback( + (created?: SamlApplicationSecret) => { + reset(); + onClose(created); + }, + [onClose, reset] + ); + const api = useApi(); + const expirationYears = watch('lifeSpanInYears'); + const [expirationDate, setExpirationDate] = useState( + addYears(new Date(), Number(expirationYears)) + ); + + // Update expiration date every minute since our options are relative to the current time (in years). + // Since we will show expire time on create secret modal in the format of `Apr 29, 1953, 12:00 AM`. + useEffect(() => { + const incrementExpirationDate = (incrementMilliseconds: number = intervalExpirationRefresh) => { + setExpirationDate(addMilliseconds(expirationDate, incrementMilliseconds)); + }; + + const interval = setInterval(incrementExpirationDate, intervalExpirationRefresh); + incrementExpirationDate(); + + return () => { + clearInterval(interval); + }; + }, [expirationYears]); + + const submit = handleSubmit( + trySubmitSafe(async ({ lifeSpanInYears }) => { + const createdData = await api + .post(`api/saml-applications/${appId}/secrets`, { + json: { lifeSpanInYears: Number(lifeSpanInYears) }, + }) + .json(); + toast.success(t('application_details.secrets.create_modal.created')); + onCloseHandler(createdData); + }) + ); + + return ( + { + onCloseHandler(); + }} + > + + } + onClose={onCloseHandler} + > + ( + + {t('application_details.secrets.create_modal.expiration_description', { + date: format(expirationDate, 'PPp'), + })} + + } + > +