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; background: transparent;
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 102;
} }

View file

@ -10,7 +10,7 @@ import { useRef } from 'react';
import ReactModal from 'react-modal'; import ReactModal from 'react-modal';
import usePosition from '@/hooks/use-position'; 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 { onKeyDownHandler } from '@/utils/a11y';
import OverlayScrollbar from '../OverlayScrollbar'; import OverlayScrollbar from '../OverlayScrollbar';
@ -29,6 +29,7 @@ type Props = {
className?: string; className?: string;
titleClassName?: string; titleClassName?: string;
horizontalAlign?: HorizontalAlignment; horizontalAlign?: HorizontalAlignment;
verticalAlign?: VerticalAlignment;
hasOverflowContent?: boolean; hasOverflowContent?: boolean;
}; };
@ -49,14 +50,15 @@ function Dropdown({
className, className,
titleClassName, titleClassName,
horizontalAlign = 'end', horizontalAlign = 'end',
verticalAlign = 'bottom',
hasOverflowContent, hasOverflowContent,
}: Props) { }: Props) {
const overlayRef = useRef<HTMLDivElement>(null); const overlayRef = useRef<HTMLDivElement>(null);
const { position, positionState, mutate } = usePosition({ const { position, positionState, mutate } = usePosition({
verticalAlign: 'bottom', verticalAlign,
horizontalAlign, horizontalAlign,
offset: { vertical: 4, horizontal: 0 }, offset: { vertical: verticalAlign === 'bottom' ? 4 : -4, horizontal: 0 },
anchorRef, anchorRef,
overlayRef, overlayRef,
}); });

View file

@ -5,6 +5,7 @@ import { useRef, useState } from 'react';
import Close from '@/assets/icons/close.svg'; import Close from '@/assets/icons/close.svg';
import KeyboardArrowDown from '@/assets/icons/keyboard-arrow-down.svg'; import KeyboardArrowDown from '@/assets/icons/keyboard-arrow-down.svg';
import KeyboardArrowUp from '@/assets/icons/keyboard-arrow-up.svg'; import KeyboardArrowUp from '@/assets/icons/keyboard-arrow-up.svg';
import { type VerticalAlignment } from '@/types/positioning';
import { onKeyDownHandler } from '@/utils/a11y'; import { onKeyDownHandler } from '@/utils/a11y';
import Dropdown, { DropdownItem } from '../Dropdown'; import Dropdown, { DropdownItem } from '../Dropdown';
@ -27,6 +28,7 @@ type Props<T> = {
placeholder?: ReactNode; placeholder?: ReactNode;
isClearable?: boolean; isClearable?: boolean;
size?: 'small' | 'medium' | 'large'; size?: 'small' | 'medium' | 'large';
dropdownPosition?: VerticalAlignment;
}; };
function Select<T extends string>({ function Select<T extends string>({
@ -39,6 +41,7 @@ function Select<T extends string>({
placeholder, placeholder,
isClearable, isClearable,
size = 'large', size = 'large',
dropdownPosition = 'bottom',
}: Props<T>) { }: Props<T>) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<HTMLInputElement>(null); const anchorRef = useRef<HTMLInputElement>(null);
@ -97,6 +100,7 @@ function Select<T extends string>({
</div> </div>
<Dropdown <Dropdown
isFullWidth isFullWidth
verticalAlign={dropdownPosition}
anchorRef={anchorRef} anchorRef={anchorRef}
className={styles.dropdown} className={styles.dropdown}
isOpen={isOpen} isOpen={isOpen}

View file

@ -124,6 +124,14 @@
.headerTable { .headerTable {
padding: 0; padding: 0;
border: 1px solid var(--color-divider); border: 1px solid var(--color-divider);
tr th:first-child {
padding-left: _.unit(7);
}
tr th:last-child {
padding-right: _.unit(7);
}
} }
.bodyTable { .bodyTable {
@ -134,6 +142,14 @@
tr:first-child td { tr:first-child td {
border-top: 1px solid transparent; border-top: 1px solid transparent;
} }
tr td:first-child {
padding-left: _.unit(7);
}
tr td:last-child {
padding-right: _.unit(7);
}
} }
tr.hoverEffect:hover { tr.hoverEffect:hover {

View file

@ -33,6 +33,7 @@ export type Props<
isLoading?: boolean; isLoading?: boolean;
pagination?: PaginationProps; pagination?: PaginationProps;
placeholder?: ReactNode; placeholder?: ReactNode;
loadingSkeleton?: ReactNode;
errorMessage?: string; errorMessage?: string;
hasBorder?: boolean; hasBorder?: boolean;
onRetry?: () => void; onRetry?: () => void;
@ -56,6 +57,7 @@ function Table<
isLoading, isLoading,
pagination, pagination,
placeholder, placeholder,
loadingSkeleton,
errorMessage, errorMessage,
hasBorder, hasBorder,
onRetry, onRetry,
@ -103,9 +105,10 @@ function Table<
> >
<table> <table>
<tbody> <tbody>
{isLoading && ( {isLoading &&
<TableLoading columnSpans={columns.map(({ colSpan }) => colSpan ?? 1)} /> (loadingSkeleton ?? (
)} <TableLoading columnSpans={columns.map(({ colSpan }) => colSpan ?? 1)} />
))}
{hasError && ( {hasError && (
<TableError columns={columns.length} content={errorMessage} onRetry={onRetry} /> <TableError columns={columns.length} content={errorMessage} onRetry={onRetry} />
)} )}
@ -145,7 +148,7 @@ function Table<
> >
{columns.map(({ dataIndex, colSpan, className, render }) => ( {columns.map(({ dataIndex, colSpan, className, render }) => (
<td key={dataIndex} colSpan={colSpan} className={className}> <td key={dataIndex} colSpan={colSpan} className={className}>
{render(row)} {render(row, rowIndex)}
</td> </td>
))} ))}
</tr> </tr>

View file

@ -4,7 +4,7 @@ import type { FieldValues } from 'react-hook-form';
export type Column<TFieldValues extends FieldValues = FieldValues> = { export type Column<TFieldValues extends FieldValues = FieldValues> = {
title: ReactNode; title: ReactNode;
dataIndex: string; dataIndex: string;
render: (row: TFieldValues) => ReactNode; render: (row: TFieldValues, rowIndex: number) => ReactNode;
colSpan?: number; colSpan?: number;
className?: string; 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 DeleteCard from './DeleteCard';
import DeleteModal from './DeleteModal'; import DeleteModal from './DeleteModal';
import ProfileForm from './ProfileForm'; import ProfileForm from './ProfileForm';
import SigningKeys from './SigningKeys';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
import { type TenantSettingsForm } from './types.js'; import { type TenantSettingsForm } from './types.js';
@ -114,6 +115,7 @@ function TenantBasicSettings() {
<FormProvider {...methods}> <FormProvider {...methods}>
<div className={styles.fields}> <div className={styles.fields}>
<ProfileForm currentTenantId={currentTenantId} /> <ProfileForm currentTenantId={currentTenantId} />
<SigningKeys />
<DeleteCard currentTenantId={currentTenantId} onClick={onClickDeletionButton} /> <DeleteCard currentTenantId={currentTenantId} onClick={onClickDeletionButton} />
</div> </div>
</FormProvider> </FormProvider>
@ -123,17 +125,17 @@ function TenantBasicSettings() {
onDiscard={reset} onDiscard={reset}
onSubmit={onSubmit} onSubmit={onSubmit}
/> />
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
<DeleteModal
isOpen={isDeletionModalOpen}
isLoading={isDeleting}
tenant={watch('profile')}
onClose={() => {
setIsDeletionModalOpen(false);
}}
onDelete={onDelete}
/>
</form> </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-toast: var(--color-neutral-20);
--color-bg-state-unselected: var(--color-neutral-90); --color-bg-state-unselected: var(--color-neutral-90);
--color-bg-state-disabled: rgba(25, 28, 29, 8%); // 8% --color-neutral-10 --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 { @mixin dark {
@ -391,4 +392,5 @@
--color-bg-toast: var(--color-neutral-80); --color-bg-toast: var(--color-neutral-80);
--color-bg-state-unselected: var(--color-neutral-90); --color-bg-state-unselected: var(--color-neutral-90);
--color-bg-state-disabled: rgba(247, 248, 248, 8%); // 8% --color-neutral-10 --color-bg-state-disabled: rgba(247, 248, 248, 8%); // 8% --color-neutral-10
--color-bg-info-tag: var(--color-neutral-variant-90);
} }