mirror of
https://github.com/logto-io/logto.git
synced 2025-03-03 22:15:32 -05:00
feat(console): add console UI for private keys rotation (#4632)
This commit is contained in:
parent
5db08a7a20
commit
106ec388d5
10 changed files with 312 additions and 18 deletions
|
@ -28,4 +28,5 @@
|
|||
background: transparent;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 102;
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { useRef } from 'react';
|
|||
import ReactModal from 'react-modal';
|
||||
|
||||
import usePosition from '@/hooks/use-position';
|
||||
import type { HorizontalAlignment } from '@/types/positioning';
|
||||
import type { HorizontalAlignment, VerticalAlignment } from '@/types/positioning';
|
||||
import { onKeyDownHandler } from '@/utils/a11y';
|
||||
|
||||
import OverlayScrollbar from '../OverlayScrollbar';
|
||||
|
@ -29,6 +29,7 @@ type Props = {
|
|||
className?: string;
|
||||
titleClassName?: string;
|
||||
horizontalAlign?: HorizontalAlignment;
|
||||
verticalAlign?: VerticalAlignment;
|
||||
hasOverflowContent?: boolean;
|
||||
};
|
||||
|
||||
|
@ -49,14 +50,15 @@ function Dropdown({
|
|||
className,
|
||||
titleClassName,
|
||||
horizontalAlign = 'end',
|
||||
verticalAlign = 'bottom',
|
||||
hasOverflowContent,
|
||||
}: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { position, positionState, mutate } = usePosition({
|
||||
verticalAlign: 'bottom',
|
||||
verticalAlign,
|
||||
horizontalAlign,
|
||||
offset: { vertical: 4, horizontal: 0 },
|
||||
offset: { vertical: verticalAlign === 'bottom' ? 4 : -4, horizontal: 0 },
|
||||
anchorRef,
|
||||
overlayRef,
|
||||
});
|
||||
|
|
|
@ -5,6 +5,7 @@ import { useRef, useState } from 'react';
|
|||
import Close from '@/assets/icons/close.svg';
|
||||
import KeyboardArrowDown from '@/assets/icons/keyboard-arrow-down.svg';
|
||||
import KeyboardArrowUp from '@/assets/icons/keyboard-arrow-up.svg';
|
||||
import { type VerticalAlignment } from '@/types/positioning';
|
||||
import { onKeyDownHandler } from '@/utils/a11y';
|
||||
|
||||
import Dropdown, { DropdownItem } from '../Dropdown';
|
||||
|
@ -27,6 +28,7 @@ type Props<T> = {
|
|||
placeholder?: ReactNode;
|
||||
isClearable?: boolean;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
dropdownPosition?: VerticalAlignment;
|
||||
};
|
||||
|
||||
function Select<T extends string>({
|
||||
|
@ -39,6 +41,7 @@ function Select<T extends string>({
|
|||
placeholder,
|
||||
isClearable,
|
||||
size = 'large',
|
||||
dropdownPosition = 'bottom',
|
||||
}: Props<T>) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLInputElement>(null);
|
||||
|
@ -97,6 +100,7 @@ function Select<T extends string>({
|
|||
</div>
|
||||
<Dropdown
|
||||
isFullWidth
|
||||
verticalAlign={dropdownPosition}
|
||||
anchorRef={anchorRef}
|
||||
className={styles.dropdown}
|
||||
isOpen={isOpen}
|
||||
|
|
|
@ -124,6 +124,14 @@
|
|||
.headerTable {
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-divider);
|
||||
|
||||
tr th:first-child {
|
||||
padding-left: _.unit(7);
|
||||
}
|
||||
|
||||
tr th:last-child {
|
||||
padding-right: _.unit(7);
|
||||
}
|
||||
}
|
||||
|
||||
.bodyTable {
|
||||
|
@ -134,6 +142,14 @@
|
|||
tr:first-child td {
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
|
||||
tr td:first-child {
|
||||
padding-left: _.unit(7);
|
||||
}
|
||||
|
||||
tr td:last-child {
|
||||
padding-right: _.unit(7);
|
||||
}
|
||||
}
|
||||
|
||||
tr.hoverEffect:hover {
|
||||
|
|
|
@ -33,6 +33,7 @@ export type Props<
|
|||
isLoading?: boolean;
|
||||
pagination?: PaginationProps;
|
||||
placeholder?: ReactNode;
|
||||
loadingSkeleton?: ReactNode;
|
||||
errorMessage?: string;
|
||||
hasBorder?: boolean;
|
||||
onRetry?: () => void;
|
||||
|
@ -56,6 +57,7 @@ function Table<
|
|||
isLoading,
|
||||
pagination,
|
||||
placeholder,
|
||||
loadingSkeleton,
|
||||
errorMessage,
|
||||
hasBorder,
|
||||
onRetry,
|
||||
|
@ -103,9 +105,10 @@ function Table<
|
|||
>
|
||||
<table>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<TableLoading columnSpans={columns.map(({ colSpan }) => colSpan ?? 1)} />
|
||||
)}
|
||||
{isLoading &&
|
||||
(loadingSkeleton ?? (
|
||||
<TableLoading columnSpans={columns.map(({ colSpan }) => colSpan ?? 1)} />
|
||||
))}
|
||||
{hasError && (
|
||||
<TableError columns={columns.length} content={errorMessage} onRetry={onRetry} />
|
||||
)}
|
||||
|
@ -145,7 +148,7 @@ function Table<
|
|||
>
|
||||
{columns.map(({ dataIndex, colSpan, className, render }) => (
|
||||
<td key={dataIndex} colSpan={colSpan} className={className}>
|
||||
{render(row)}
|
||||
{render(row, rowIndex)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
|
|
@ -4,7 +4,7 @@ import type { FieldValues } from 'react-hook-form';
|
|||
export type Column<TFieldValues extends FieldValues = FieldValues> = {
|
||||
title: ReactNode;
|
||||
dataIndex: string;
|
||||
render: (row: TFieldValues) => ReactNode;
|
||||
render: (row: TFieldValues, rowIndex: number) => ReactNode;
|
||||
colSpan?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.idWrapper {
|
||||
font: var(--font-body-2);
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
color: var(--color-text);
|
||||
line-height: 26px;
|
||||
padding: 0 _.unit(2);
|
||||
border-radius: 6px;
|
||||
background-color: var(--color-bg-info-tag);
|
||||
}
|
||||
|
||||
.deleteIcon {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.rotateKey {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 8px;
|
||||
padding: _.unit(4);
|
||||
gap: _.unit(2);
|
||||
|
||||
.description {
|
||||
flex: 1;
|
||||
font: var(--font-body-2);
|
||||
}
|
||||
}
|
||||
|
||||
.bone {
|
||||
@include _.shimmering-animation;
|
||||
height: 26px;
|
||||
max-width: 344px;
|
||||
}
|
|
@ -0,0 +1,227 @@
|
|||
import {
|
||||
LogtoOidcConfigKey,
|
||||
SupportedSigningKeyAlgorithm,
|
||||
type OidcConfigKeysResponse,
|
||||
} from '@logto/schemas';
|
||||
import { condArray } from '@silverhand/essentials';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import Delete from '@/assets/icons/delete.svg';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import Button from '@/ds-components/Button';
|
||||
import DangerConfirmModal from '@/ds-components/DeleteConfirmModal';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import IconButton from '@/ds-components/IconButton';
|
||||
import Select from '@/ds-components/Select';
|
||||
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
|
||||
import Table from '@/ds-components/Table';
|
||||
import Tag from '@/ds-components/Tag';
|
||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
function SigningKeys() {
|
||||
const api = useApi();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.tenants.signing_keys' });
|
||||
const [activeTab, setActiveTab] = useState<LogtoOidcConfigKey>(LogtoOidcConfigKey.PrivateKeys);
|
||||
const keyType = activeTab === LogtoOidcConfigKey.PrivateKeys ? 'private-keys' : 'cookie-keys';
|
||||
const entities = activeTab === LogtoOidcConfigKey.PrivateKeys ? 'tokens' : 'cookies';
|
||||
|
||||
const { data, error, mutate } = useSWR<OidcConfigKeysResponse[], RequestError>(
|
||||
`api/configs/oidc/${keyType}`
|
||||
);
|
||||
const [deletingKeyId, setDeletingKeyId] = useState<string>();
|
||||
const [showRotateConfirmModal, setShowRotateConfirmModal] = useState(false);
|
||||
const [isRotating, setIsRotating] = useState(false);
|
||||
const [rotateKeyAlgorithm, setRotateKeyAlgorithm] = useState<SupportedSigningKeyAlgorithm>(
|
||||
SupportedSigningKeyAlgorithm.EC
|
||||
);
|
||||
|
||||
const isLoadingKeys = !data && !error;
|
||||
|
||||
const tableColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('table_column.id'),
|
||||
dataIndex: 'id',
|
||||
colSpan: 8,
|
||||
render: ({ id }: OidcConfigKeysResponse) => <span className={styles.idWrapper}>{id}</span>,
|
||||
},
|
||||
{
|
||||
title: t('table_column.status'),
|
||||
dataIndex: 'status',
|
||||
colSpan: 4,
|
||||
render: (_: OidcConfigKeysResponse, rowIndex: number) => (
|
||||
<Tag type="state" status={rowIndex === 0 ? 'success' : 'alert'}>
|
||||
{t(rowIndex === 0 ? 'status.current' : 'status.previous')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
...condArray(
|
||||
activeTab === LogtoOidcConfigKey.PrivateKeys && [
|
||||
{
|
||||
title: t('table_column.algorithm'),
|
||||
dataIndex: 'signingKeyAlgorithm',
|
||||
colSpan: 7,
|
||||
render: ({ signingKeyAlgorithm }: OidcConfigKeysResponse) => (
|
||||
<span>{signingKeyAlgorithm}</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
),
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'action',
|
||||
colSpan: 2,
|
||||
render: ({ id }, rowIndex) =>
|
||||
rowIndex !== 0 && (
|
||||
<div className={styles.deleteIcon}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setDeletingKeyId(id);
|
||||
}}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[activeTab, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormCard title="tenants.signing_keys.title" description="tenants.signing_keys.description">
|
||||
<TabNav>
|
||||
<TabNavItem
|
||||
isActive={activeTab === LogtoOidcConfigKey.PrivateKeys}
|
||||
onClick={() => {
|
||||
setActiveTab(LogtoOidcConfigKey.PrivateKeys);
|
||||
}}
|
||||
>
|
||||
<DynamicT forKey="tenants.signing_keys.type.private_key" />
|
||||
</TabNavItem>
|
||||
<TabNavItem
|
||||
isActive={activeTab === LogtoOidcConfigKey.CookieKeys}
|
||||
onClick={() => {
|
||||
setActiveTab(LogtoOidcConfigKey.CookieKeys);
|
||||
}}
|
||||
>
|
||||
<DynamicT forKey="tenants.signing_keys.type.cookie_key" />
|
||||
</TabNavItem>
|
||||
</TabNav>
|
||||
<FormField title="tenants.signing_keys.private_keys_in_use">
|
||||
<Table
|
||||
hasBorder
|
||||
isLoading={isLoadingKeys || isRotating}
|
||||
errorMessage={error?.body?.message ?? error?.message}
|
||||
rowIndexKey="id"
|
||||
rowGroups={[{ key: 'signing_keys', data }]}
|
||||
columns={tableColumns}
|
||||
loadingSkeleton={
|
||||
<>
|
||||
{Array.from({ length: 2 }).map((_, rowIndex) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<tr key={`skeleton-row-${rowIndex}`}>
|
||||
{tableColumns.map(({ colSpan }, columnIndex) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<td key={columnIndex} colSpan={colSpan}>
|
||||
<div className={styles.bone} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="tenants.signing_keys.rotate_private_keys">
|
||||
<div className={styles.rotateKey}>
|
||||
<div className={styles.description}>
|
||||
{t('rotate_private_keys_description', { entities })}
|
||||
</div>
|
||||
<Button
|
||||
title="tenants.signing_keys.rotate_private_keys"
|
||||
type="default"
|
||||
onClick={() => {
|
||||
setShowRotateConfirmModal(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<DangerConfirmModal
|
||||
confirmButtonText="tenants.signing_keys.rotate_button"
|
||||
isOpen={showRotateConfirmModal}
|
||||
onCancel={() => {
|
||||
setShowRotateConfirmModal(false);
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
setIsRotating(true);
|
||||
setShowRotateConfirmModal(false);
|
||||
try {
|
||||
const keys = await api
|
||||
.post(`api/configs/oidc/${keyType}/rotate`, {
|
||||
json: { signingKeyAlgorithm: rotateKeyAlgorithm },
|
||||
})
|
||||
.json<OidcConfigKeysResponse[]>();
|
||||
void mutate(keys);
|
||||
} finally {
|
||||
setIsRotating(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<Trans components={{ strong: <strong /> }}>
|
||||
{t('reminder.rotate', { key: activeTab, entities })}
|
||||
</Trans>
|
||||
</span>
|
||||
{activeTab === LogtoOidcConfigKey.PrivateKeys && (
|
||||
<FormField title="tenants.signing_keys.select_private_key_algorithm">
|
||||
<Select
|
||||
options={Object.values(SupportedSigningKeyAlgorithm).map((value) => ({
|
||||
title: value,
|
||||
value,
|
||||
}))}
|
||||
dropdownPosition="top"
|
||||
value={rotateKeyAlgorithm}
|
||||
onChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setRotateKeyAlgorithm(value);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</DangerConfirmModal>
|
||||
<DangerConfirmModal
|
||||
isOpen={Boolean(deletingKeyId)}
|
||||
onCancel={() => {
|
||||
setDeletingKeyId(undefined);
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!deletingKeyId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.delete(`api/configs/oidc/${keyType}/${deletingKeyId}`);
|
||||
void mutate(data?.filter((key) => key.id !== deletingKeyId));
|
||||
} finally {
|
||||
setDeletingKeyId(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<Trans components={{ strong: <strong /> }}>
|
||||
{t('reminder.delete', { key: activeTab, entities })}
|
||||
</Trans>
|
||||
</span>
|
||||
</DangerConfirmModal>
|
||||
</FormCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default SigningKeys;
|
|
@ -16,6 +16,7 @@ import { TenantsContext } from '@/contexts/TenantsProvider';
|
|||
import DeleteCard from './DeleteCard';
|
||||
import DeleteModal from './DeleteModal';
|
||||
import ProfileForm from './ProfileForm';
|
||||
import SigningKeys from './SigningKeys';
|
||||
import * as styles from './index.module.scss';
|
||||
import { type TenantSettingsForm } from './types.js';
|
||||
|
||||
|
@ -114,6 +115,7 @@ function TenantBasicSettings() {
|
|||
<FormProvider {...methods}>
|
||||
<div className={styles.fields}>
|
||||
<ProfileForm currentTenantId={currentTenantId} />
|
||||
<SigningKeys />
|
||||
<DeleteCard currentTenantId={currentTenantId} onClick={onClickDeletionButton} />
|
||||
</div>
|
||||
</FormProvider>
|
||||
|
@ -123,17 +125,17 @@ function TenantBasicSettings() {
|
|||
onDiscard={reset}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
|
||||
<DeleteModal
|
||||
isOpen={isDeletionModalOpen}
|
||||
isLoading={isDeleting}
|
||||
tenant={watch('profile')}
|
||||
onClose={() => {
|
||||
setIsDeletionModalOpen(false);
|
||||
}}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</form>
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
|
||||
<DeleteModal
|
||||
isOpen={isDeletionModalOpen}
|
||||
isLoading={isDeleting}
|
||||
tenant={watch('profile')}
|
||||
onClose={() => {
|
||||
setIsDeletionModalOpen(false);
|
||||
}}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -192,6 +192,7 @@
|
|||
--color-bg-toast: var(--color-neutral-20);
|
||||
--color-bg-state-unselected: var(--color-neutral-90);
|
||||
--color-bg-state-disabled: rgba(25, 28, 29, 8%); // 8% --color-neutral-10
|
||||
--color-bg-info-tag: rgba(229, 225, 236, 80%); // 80% --color-neutral-variant-90
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
|
@ -391,4 +392,5 @@
|
|||
--color-bg-toast: var(--color-neutral-80);
|
||||
--color-bg-state-unselected: var(--color-neutral-90);
|
||||
--color-bg-state-disabled: rgba(247, 248, 248, 8%); // 8% --color-neutral-10
|
||||
--color-bg-info-tag: var(--color-neutral-variant-90);
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue