0
Fork 0
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:
Charles Zhao 2023-10-11 03:11:22 -05:00 committed by GitHub
parent 5db08a7a20
commit 106ec388d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 312 additions and 18 deletions

View file

@ -28,4 +28,5 @@
background: transparent;
position: fixed;
inset: 0;
z-index: 102;
}

View file

@ -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,
});

View file

@ -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}

View file

@ -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 {

View file

@ -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>

View file

@ -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;
};

View file

@ -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;
}

View file

@ -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;

View file

@ -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}
/>
</>
);
}

View file

@ -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);
}