0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

refactor(console): improve responsiveness in all details pages (#4712)

This commit is contained in:
Charles Zhao 2023-10-24 21:52:19 +08:00 committed by GitHub
parent 1da511ad55
commit a976799621
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 738 additions and 914 deletions

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.49992 8.33342H8.33325C8.55427 8.33342 8.76623 8.24562 8.92251 8.08934C9.07879 7.93306 9.16658 7.7211 9.16658 7.50008C9.16658 7.27907 9.07879 7.06711 8.92251 6.91083C8.76623 6.75455 8.55427 6.66675 8.33325 6.66675H7.49992C7.2789 6.66675 7.06694 6.75455 6.91066 6.91083C6.75438 7.06711 6.66659 7.27907 6.66659 7.50008C6.66659 7.7211 6.75438 7.93306 6.91066 8.08934C7.06694 8.24562 7.2789 8.33342 7.49992 8.33342ZM7.49992 10.0001C7.2789 10.0001 7.06694 10.0879 6.91066 10.2442C6.75438 10.4004 6.66659 10.6124 6.66659 10.8334C6.66659 11.0544 6.75438 11.2664 6.91066 11.4227C7.06694 11.579 7.2789 11.6667 7.49992 11.6667H12.4999C12.7209 11.6667 12.9329 11.579 13.0892 11.4227C13.2455 11.2664 13.3333 11.0544 13.3333 10.8334C13.3333 10.6124 13.2455 10.4004 13.0892 10.2442C12.9329 10.0879 12.7209 10.0001 12.4999 10.0001H7.49992ZM16.6666 7.45008C16.6579 7.37353 16.6411 7.29811 16.6166 7.22508V7.15008C16.5765 7.0644 16.5231 6.98564 16.4583 6.91675L11.4583 1.91675C11.3894 1.85193 11.3106 1.79848 11.2249 1.75841C11.2 1.75488 11.1748 1.75488 11.1499 1.75841C11.0653 1.70987 10.9718 1.6787 10.8749 1.66675H5.83325C5.17021 1.66675 4.53433 1.93014 4.06549 2.39898C3.59664 2.86782 3.33325 3.50371 3.33325 4.16675V15.8334C3.33325 16.4965 3.59664 17.1323 4.06549 17.6012C4.53433 18.07 5.17021 18.3334 5.83325 18.3334H14.1666C14.8296 18.3334 15.4655 18.07 15.9344 17.6012C16.4032 17.1323 16.6666 16.4965 16.6666 15.8334V7.50008C16.6666 7.50008 16.6666 7.50008 16.6666 7.45008ZM11.6666 4.50841L13.8249 6.66675H12.4999C12.2789 6.66675 12.0669 6.57895 11.9107 6.42267C11.7544 6.26639 11.6666 6.05443 11.6666 5.83342V4.50841ZM14.9999 15.8334C14.9999 16.0544 14.9121 16.2664 14.7558 16.4227C14.5996 16.579 14.3876 16.6667 14.1666 16.6667H5.83325C5.61224 16.6667 5.40028 16.579 5.244 16.4227C5.08772 16.2664 4.99992 16.0544 4.99992 15.8334V4.16675C4.99992 3.94573 5.08772 3.73377 5.244 3.57749C5.40028 3.42121 5.61224 3.33341 5.83325 3.33341H9.99992V5.83342C9.99992 6.49646 10.2633 7.13234 10.7322 7.60118C11.201 8.07002 11.8369 8.33342 12.4999 8.33342H14.9999V15.8334ZM12.4999 13.3334H7.49992C7.2789 13.3334 7.06694 13.4212 6.91066 13.5775C6.75438 13.7338 6.66659 13.9457 6.66659 14.1667C6.66659 14.3878 6.75438 14.5997 6.91066 14.756C7.06694 14.9123 7.2789 15.0001 7.49992 15.0001H12.4999C12.7209 15.0001 12.9329 14.9123 13.0892 14.756C13.2455 14.5997 13.3333 14.3878 13.3333 14.1667C13.3333 13.9457 13.2455 13.7338 13.0892 13.5775C12.9329 13.4212 12.7209 13.3334 12.4999 13.3334Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,56 @@
@use '@/scss/underscore' as _;
.header {
flex: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: _.unit(6) _.unit(8);
gap: _.unit(6);
.icon {
width: 60px;
height: 60px;
flex-shrink: 0;
}
.copyId {
flex: 1;
}
.operations {
display: flex;
align-items: center;
gap: _.unit(3);
svg {
color: var(--color-text-secondary);
}
}
.metadata {
flex: 1;
.name {
font: var(--font-title-1);
color: var(--color-text);
}
.row {
display: flex;
align-items: center;
white-space: nowrap;
gap: _.unit(2);
.text {
font: var(--font-label-2);
color: var(--color-text-secondary);
}
.verticalBar {
@include _.vertical-bar;
height: 12px;
}
}
}
}

View file

@ -0,0 +1,238 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { conditional } from '@silverhand/essentials';
import {
type ReactNode,
type ReactElement,
cloneElement,
isValidElement,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import More from '@/assets/icons/more.svg';
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
import Button from '@/ds-components/Button';
import Card from '@/ds-components/Card';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import { type Props as DropdownItemProps } from '@/ds-components/Dropdown/DropdownItem';
import DynamicT from '@/ds-components/DynamicT';
import Tag, { type Props as TagProps } from '@/ds-components/Tag';
import useWindowResize from '@/hooks/use-window-resize';
import * as styles from './index.module.scss';
type StatusTag = {
status: TagProps['status'];
text: AdminConsoleKey;
};
type Identifier = {
name: string;
value: string;
};
type AdditionalActionButton = {
title: AdminConsoleKey;
icon: ReactNode;
onClick: () => void;
};
export type MenuItem = {
type?: DropdownItemProps['type'];
title: AdminConsoleKey;
icon: ReactNode;
onClick: () => void;
};
type ResponsiveCustomElement = HTMLDivElement & {
isCompact: boolean;
};
type Props = {
/**
* The main 60x60 icon on the very left
*/
icon: ReactElement<HTMLElement>;
/**
* The main title of the header
*/
title: ReactNode;
/**
* Shows a subtitle in the second row
* Example usage: Secondary information of the user (if any) in user details page
*/
subtitle?: ReactNode;
/**
* Shows a tag in the second row of the header metadata
* Example usage: Application type "Native / SPA / Traditional"
*/
primaryTag?: ReactNode;
/**
* Shows a status tag in the second row of the header metadata
* Example usage: Connector status "In use / Not in use" in connector details page
*/
statusTag?: StatusTag;
/**
* Shows the entity identifier in a "Copy to clipboard" component
* Example usage: "App ID" in application details page
*/
identifier?: Identifier;
/**
* Shows an additional action button in the header, next to the "...(More)" button
* Example usage: "Check Guide" button in application details page
*/
additionalActionButton?: AdditionalActionButton;
/**
* Shows additional custom element in the header, next to the "...(More)" button
* Example usage (special use case): "Total email sent (count)" in Logto email connector
*/
additionalCustomElement?: ReactElement<ResponsiveCustomElement>;
/**
* Dropdown action menu items nested in the "...(More)" button
*/
actionMenuItems?: MenuItem[];
};
function DetailsPageHeader({
icon,
title,
subtitle,
primaryTag,
statusTag,
identifier,
additionalActionButton,
additionalCustomElement,
actionMenuItems,
}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [showIcon, setShowIcon] = useState(true);
const [isCompact, setIsCompact] = useState(false);
const [showAdditionalCustomElement, setShowAdditionalCustomElement] = useState(true);
const identifierRef = useRef<HTMLDivElement>(null);
const operationRef = useRef<HTMLDivElement>(null);
useWindowResize(() => {
if (!identifierRef.current || !operationRef.current) {
return;
}
// Dynamically handle the visibility of the icon and action button styles. Sources:
// https://www.figma.com/file/hqAWH3Di8gkiV5TXAt6juO/%F0%9F%8C%B9-%5BAC%5D-Layout-UI-Optimization?type=design&node-id=896-75673
const { right: identifierRightEdge, width: identifierWidth } =
identifierRef.current.getBoundingClientRect();
const operationLeftEdge = operationRef.current.getBoundingClientRect().left;
// When the operation buttons are in regular form, and the gap between the operation area and the identifier copy box is
// only 24px. This means the window is shrinking and reaching the 1st breakpoint. Set operation buttons to compact form.
if (!isCompact && operationLeftEdge - identifierRightEdge <= 24) {
setIsCompact(true);
}
// When the operation buttons are compact, and the gap between the operation area and the identifier copy box is only 24px.
// This means the window keeps shrinking and reaching the 2nd breakpoint. Hide the main icon on the very left.
if (isCompact && showIcon && operationLeftEdge - identifierRightEdge <= 24) {
setShowIcon(false);
}
// When the identifier copy box is 50px, and the gap between the operation area and the identifier copy box is only 24px.
// This is when the page header is extremely narrow and barely has space to hold the identifier. Hide the additional custom element.
if (identifierWidth <= 50 && operationLeftEdge - identifierRightEdge <= 24) {
setShowAdditionalCustomElement(false);
}
// When the gap between the operation buttons and the identifier copy box is greater than 80px, show the additional custom element.
if (!showAdditionalCustomElement && operationLeftEdge - identifierRightEdge > 80) {
setShowAdditionalCustomElement(true);
}
// When the operation buttons are compact, icon is hidden, and the operation area is 120px away from the identifier copy box.
// This means the window is enlarging and there is enough room to hold the icon. Show the icon.
// 120px is a bit greater than the space required to hold the icon (60px + 24px padding), in order to avoid jittering.
if (isCompact && !showIcon && operationLeftEdge - identifierRightEdge > 120) {
setShowIcon(true);
}
// When the operation buttons are compact, icon is visible, and the operation area is 240px away from the identifier copy box.
// This means the window width keeps increasing, and there is enough room to hold the regular size operation buttons.
// Set compact operation buttons to regular form. Also, 240px is a bit greater than the actual space required to hold the regular
// form operation buttons (around 180 ~ 240px in various cases), and this is also to avoid jittering.
if (
isCompact &&
showIcon &&
operationLeftEdge - identifierRightEdge > (additionalCustomElement ? 240 : 180)
) {
setIsCompact(false);
}
});
return (
<Card className={styles.header}>
{showIcon && isValidElement(icon) && cloneElement(icon, { className: styles.icon })}
<div className={styles.metadata}>
<div className={styles.name}>{title}</div>
<div className={styles.row}>
{primaryTag && (
<>
{typeof primaryTag === 'string' ? <Tag>{primaryTag}</Tag> : primaryTag}
<div className={styles.verticalBar} />
</>
)}
{subtitle && (
<>
<div className={styles.text}>{subtitle}</div>
<div className={styles.verticalBar} />
</>
)}
{statusTag && (
<>
<Tag type="state" status={statusTag.status}>
<DynamicT forKey={statusTag.text} />
</Tag>
<div className={styles.verticalBar} />
</>
)}
{identifier && (
<>
<div className={styles.text}>{identifier.name}</div>
<CopyToClipboard
ref={identifierRef}
className={styles.copyId}
// It's OK to use `ch` here because the font is monospace. 40px is the copy icon + padding.
style={{ maxWidth: `calc(${identifier.value.length}ch + 40px)` }}
valueStyle={{ width: 0 }}
size="small"
value={identifier.value}
/>
</>
)}
</div>
</div>
<div ref={operationRef} className={styles.operations}>
{showAdditionalCustomElement &&
isValidElement(additionalCustomElement) &&
cloneElement<ResponsiveCustomElement>(additionalCustomElement, { isCompact })}
{additionalActionButton && (
<Button
icon={conditional(isCompact && additionalActionButton.icon)}
title={conditional(!isCompact && additionalActionButton.title)}
size="large"
onClick={additionalActionButton.onClick}
/>
)}
<ActionMenu
buttonProps={{ icon: <More />, size: 'large' }}
title={t('general.more_options')}
>
{actionMenuItems?.map(({ title, icon, type, onClick }) => (
<ActionMenuItem key={title} icon={icon} type={type} onClick={onClick}>
<DynamicT forKey={title} />
</ActionMenuItem>
))}
</ActionMenu>
</div>
</Card>
);
}
export default DetailsPageHeader;

View file

@ -1,34 +1,41 @@
@use '@/scss/underscore' as _;
.container {
padding: _.unit(6) _.unit(8);
display: flex;
}
$gutter-width: 12px;
$column-width: calc((100% - 23 * $gutter-width) / 24);
.introduction {
width: 296px;
padding-bottom: _.unit(6);
margin-right: _.unit(14);
flex-shrink: 0;
.responsiveWrapper {
width: 100%;
container-type: inline-size;
> :not(:first-child) {
margin-top: _.unit(2);
}
}
.form {
flex-grow: 1;
overflow: hidden;
padding: 0 _.unit(1);
}
@media screen and (max-width: 1080px) {
.container {
flex-direction: column;
width: 100%;
padding: _.unit(6) _.unit(8);
display: flex;
justify-content: space-between;
}
.introduction {
.introduction {
width: calc($column-width * 7 + $gutter-width * 6);
display: flex;
flex-direction: column;
gap: _.unit(2);
}
.form {
width: calc($column-width * 16 + $gutter-width * 15);
overflow: hidden;
}
@container (max-width: 600px) {
.container {
flex-direction: column;
justify-content: unset;
gap: _.unit(4);
}
.introduction,
.form {
width: 100%;
margin-right: unset;
}
}
}

View file

@ -11,10 +11,12 @@ type Props = {
function FormCardLayout({ introduction, children }: Props) {
return (
<Card className={styles.container}>
<div className={styles.introduction}>{introduction}</div>
<div className={styles.form}>{children}</div>
</Card>
<div className={styles.responsiveWrapper}>
<Card className={styles.container}>
<div className={styles.introduction}>{introduction}</div>
<div className={styles.form}>{children}</div>
</Card>
</div>
);
}

View file

@ -1,22 +1,27 @@
import { type HookResponse } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import Tag from '@/ds-components/Tag';
type Props = {
successCount: number;
totalCount: number;
className?: string;
stats?: HookResponse['executionStats'];
isNumberOnly?: boolean;
};
function SuccessRate({ successCount, totalCount, className }: Props) {
if (totalCount === 0) {
function SuccessRate({ stats, isNumberOnly }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { successCount, requestCount } = stats ?? { successCount: 0, requestCount: 0 };
if (requestCount === 0) {
return <div>-</div>;
}
const percent = (successCount / totalCount) * 100;
const percent = (successCount / requestCount) * 100;
const statusStyle = percent < 90 ? 'error' : percent < 99 ? 'alert' : 'success';
return (
<Tag variant="plain" type="state" status={statusStyle} className={className}>
{percent.toFixed(2)}%
<Tag variant="plain" type="state" status={statusStyle}>
{percent.toFixed(2)}%{!isNumberOnly && ` ${t('webhook_details.success_rate')}`}
</Tag>
);
}

View file

@ -14,7 +14,6 @@
width: 100%;
height: 100%;
padding: 0 _.unit(6) 0 _.unit(2);
min-width: 636px;
> * {
@include _.main-content-width;

View file

@ -43,10 +43,7 @@
.icon {
display: flex;
align-items: center;
&:not(:last-child) {
margin-right: _.unit(2);
}
gap: _.unit(2);
}
.trailingIcon {

View file

@ -1,7 +1,15 @@
import classNames from 'classnames';
import type { TFuncKey } from 'i18next';
import type { MouseEventHandler } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
type CSSProperties,
type ForwardedRef,
type MouseEventHandler,
forwardRef,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import Copy from '@/assets/icons/copy.svg';
@ -17,6 +25,8 @@ import * as styles from './index.module.scss';
type Props = {
value: string;
className?: string;
style?: CSSProperties;
valueStyle?: CSSProperties;
variant?: 'text' | 'contained' | 'border' | 'icon';
hasVisibilityToggle?: boolean;
size?: 'default' | 'small';
@ -25,14 +35,19 @@ type Props = {
type CopyState = TFuncKey<'translation', 'admin_console.general'>;
function CopyToClipboard({
value,
className,
hasVisibilityToggle,
variant = 'contained',
size = 'default',
isWordWrapAllowed = false,
}: Props) {
function CopyToClipboard(
{
value,
className,
style,
valueStyle,
hasVisibilityToggle,
variant = 'contained',
size = 'default',
isWordWrapAllowed = false,
}: Props,
ref: ForwardedRef<HTMLDivElement>
) {
const copyIconReference = useRef<HTMLButtonElement>(null);
const [copyState, setCopyState] = useState<CopyState>('copy');
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.general' });
@ -64,9 +79,11 @@ function CopyToClipboard({
return (
<div
ref={ref}
className={classNames(styles.container, styles[variant], styles[size], className)}
role="button"
tabIndex={0}
style={style}
onKeyDown={onKeyDownHandler((event) => {
event.stopPropagation();
})}
@ -76,7 +93,10 @@ function CopyToClipboard({
>
<div className={styles.row}>
{variant !== 'icon' && (
<div className={classNames(styles.content, isWordWrapAllowed && styles.wrapContent)}>
<div
className={classNames(styles.content, isWordWrapAllowed && styles.wrapContent)}
style={valueStyle}
>
{displayValue}
</div>
)}
@ -112,4 +132,4 @@ function CopyToClipboard({
);
}
export default CopyToClipboard;
export default forwardRef(CopyToClipboard);

View file

@ -5,7 +5,7 @@ import { onKeyDownHandler } from '@/utils/a11y';
import * as styles from './DropdownItem.module.scss';
type Props = {
export type Props = {
onClick?: (event: MouseEvent<HTMLDivElement> | KeyboardEvent<HTMLDivElement>) => void;
className?: string;
children: ReactNode;

View file

@ -4,6 +4,7 @@
display: inline-flex;
align-items: center;
font: var(--font-body-2);
@include _.text-ellipsis;
.icon {
width: 10px;

View file

@ -17,62 +17,3 @@
color: var(--color-primary-50);
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
.info {
display: flex;
.icon {
margin-left: _.unit(2);
width: 60px;
height: 60px;
object-fit: cover;
}
.metadata {
margin-left: _.unit(6);
display: flex;
flex-direction: column;
justify-content: space-between;
.name {
font: var(--font-title-1);
color: var(--color-text);
}
.row {
display: flex;
align-items: center;
gap: _.unit(1);
}
.text {
font: var(--font-label-2);
color: var(--color-text-secondary);
}
.verticalBar {
@include _.vertical-bar;
height: 12px;
margin: 0 _.unit(2);
}
}
}
.operations {
display: flex;
align-items: center;
.moreIcon {
color: var(--color-text-secondary);
}
> *:not(:first-child) {
margin-left: _.unit(3);
}
}
}

View file

@ -11,20 +11,16 @@ import useSWR from 'swr';
import ApiResourceDark from '@/assets/icons/api-resource-dark.svg';
import ApiResource from '@/assets/icons/api-resource.svg';
import Delete from '@/assets/icons/delete.svg';
import File from '@/assets/icons/file.svg';
import ManagementApiResourceDark from '@/assets/icons/management-api-resource-dark.svg';
import ManagementApiResource from '@/assets/icons/management-api-resource.svg';
import More from '@/assets/icons/more.svg';
import DetailsPage from '@/components/DetailsPage';
import DetailsPageHeader from '@/components/DetailsPage/DetailsPageHeader';
import Drawer from '@/components/Drawer';
import PageMeta from '@/components/PageMeta';
import { ApiResourceDetailsTabs } from '@/consts/page-tabs';
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
import Button from '@/ds-components/Button';
import Card from '@/ds-components/Card';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import Tag from '@/ds-components/Tag';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useTenantPathname from '@/hooks/use-tenant-pathname';
@ -113,69 +109,52 @@ function ApiResourceDetails() {
{isLogtoManagementApiResource && <ManagementApiNotice />}
{data && (
<>
<Card className={styles.header}>
<div className={styles.info}>
<Icon className={styles.icon} />
<div className={styles.metadata}>
<div className={styles.name}>{data.name}</div>
<div className={styles.row}>
{data.isDefault && (
<>
<Tag>{t('api_resources.default_api')}</Tag>
<div className={styles.verticalBar} />
</>
)}
<div className={styles.text}>API Identifier</div>
<CopyToClipboard size="small" value={data.indicator} />
</div>
</div>
<DetailsPageHeader
icon={<Icon />}
title={data.name}
primaryTag={data.isDefault && t('api_resources.default_api')}
identifier={{
name: 'API Identifier',
value: data.indicator,
}}
additionalActionButton={{
icon: <File />,
title: 'application_details.check_guide',
onClick: () => {
setIsGuideDrawerOpen(true);
},
}}
actionMenuItems={[
{
icon: <Delete />,
title: 'general.delete',
type: 'danger',
onClick: () => {
setIsDeleteFormOpen(true);
},
},
]}
/>
<Drawer isOpen={isGuideDrawerOpen} onClose={onCloseDrawer}>
<GuideDrawer apiResource={data} onClose={onCloseDrawer} />
</Drawer>
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
isLoading={isDeleting}
expectedInput={data.name}
className={styles.deleteConfirm}
inputPlaceholder={t('api_resource_details.enter_your_api_resource_name')}
onCancel={() => {
setIsDeleteFormOpen(false);
}}
onConfirm={onDelete}
>
<div className={styles.description}>
<Trans components={{ span: <span className={styles.highlight} /> }}>
{t('api_resource_details.delete_description', { name: data.name })}
</Trans>
</div>
{!isLogtoManagementApiResource && (
<div className={styles.operations}>
<Button
title="application_details.check_guide"
size="large"
onClick={() => {
setIsGuideDrawerOpen(true);
}}
/>
<Drawer isOpen={isGuideDrawerOpen} onClose={onCloseDrawer}>
<GuideDrawer apiResource={data} onClose={onCloseDrawer} />
</Drawer>
<ActionMenu
buttonProps={{ icon: <More className={styles.moreIcon} />, size: 'large' }}
title={t('general.more_options')}
>
<ActionMenuItem
icon={<Delete />}
type="danger"
onClick={() => {
setIsDeleteFormOpen(true);
}}
>
{t('general.delete')}
</ActionMenuItem>
</ActionMenu>
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
isLoading={isDeleting}
expectedInput={data.name}
className={styles.deleteConfirm}
inputPlaceholder={t('api_resource_details.enter_your_api_resource_name')}
onCancel={() => {
setIsDeleteFormOpen(false);
}}
onConfirm={onDelete}
>
<div className={styles.description}>
<Trans components={{ span: <span className={styles.highlight} /> }}>
{t('api_resource_details.delete_description', { name: data.name })}
</Trans>
</div>
</DeleteConfirmModal>
</div>
)}
</Card>
</DeleteConfirmModal>
<TabNav>
<TabNavItem href={`/api-resources/${data.id}/${ApiResourceDetailsTabs.Settings}`}>
{t('api_resource_details.settings_tab')}

View file

@ -18,68 +18,6 @@
@include _.form-text-field;
}
.header {
flex: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: _.unit(6);
> *:not(:first-child) {
margin-left: _.unit(6);
}
.icon {
margin-left: _.unit(2);
width: 60px;
height: 60px;
}
.operations {
display: flex;
align-items: center;
.moreIcon {
color: var(--color-text-secondary);
}
> :not(:first-child) {
margin-left: _.unit(3);
}
}
.metadata {
flex: 1;
.name {
font: var(--font-title-1);
color: var(--color-text);
}
.row {
white-space: nowrap;
> * {
display: inline-block;
}
> :not(:first-child) {
margin-left: _.unit(2);
}
.text {
font: var(--font-label-2);
color: var(--color-text-secondary);
}
.verticalBar {
@include _.vertical-bar;
height: 12px;
}
}
}
}
.customEndpointNotes {
margin-top: _.unit(6);
font: var(--font-body-2);

View file

@ -14,23 +14,19 @@ import { useParams } from 'react-router-dom';
import useSWR from 'swr';
import Delete from '@/assets/icons/delete.svg';
import More from '@/assets/icons/more.svg';
import File from '@/assets/icons/file.svg';
import ApplicationIcon from '@/components/ApplicationIcon';
import DetailsForm from '@/components/DetailsForm';
import DetailsPage from '@/components/DetailsPage';
import DetailsPageHeader from '@/components/DetailsPage/DetailsPageHeader';
import Drawer from '@/components/Drawer';
import PageMeta from '@/components/PageMeta';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { ApplicationDetailsTabs } from '@/consts';
import { openIdProviderConfigPath } from '@/consts/oidc';
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
import Button from '@/ds-components/Button';
import Card from '@/ds-components/Card';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import TabWrapper from '@/ds-components/TabWrapper';
import Tag from '@/ds-components/Tag';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useTenantPathname from '@/hooks/use-tenant-pathname';
@ -173,61 +169,49 @@ function ApplicationDetails() {
<PageMeta titleKey="application_details.page_title" />
{data && oidcConfig && (
<>
<Card className={styles.header}>
<ApplicationIcon type={data.type} className={styles.icon} />
<div className={styles.metadata}>
<div className={styles.name}>{data.name}</div>
<div className={styles.row}>
<Tag>{t(`${applicationTypeI18nKey[data.type]}.title`)}</Tag>
<div className={styles.verticalBar} />
<div className={styles.text}>App ID</div>
<CopyToClipboard size="small" value={data.id} />
</div>
<DetailsPageHeader
icon={<ApplicationIcon type={data.type} />}
title={data.name}
primaryTag={t(`${applicationTypeI18nKey[data.type]}.title`)}
identifier={{ name: 'App ID', value: data.id }}
additionalActionButton={{
title: 'application_details.check_guide',
icon: <File />,
onClick: () => {
setIsReadmeOpen(true);
},
}}
actionMenuItems={[
{
type: 'danger',
title: 'general.delete',
icon: <Delete />,
onClick: () => {
setIsDeleteFormOpen(true);
},
},
]}
/>
<Drawer isOpen={isReadmeOpen} onClose={onCloseDrawer}>
<GuideDrawer app={data} onClose={onCloseDrawer} />
</Drawer>
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
isLoading={isDeleting}
expectedInput={data.name}
inputPlaceholder={t('application_details.enter_your_application_name')}
className={styles.deleteConfirm}
onCancel={() => {
setIsDeleteFormOpen(false);
}}
onConfirm={onDelete}
>
<div className={styles.description}>
<Trans components={{ span: <span className={styles.highlight} /> }}>
{t('application_details.delete_description', { name: data.name })}
</Trans>
</div>
<div className={styles.operations}>
<Button
title="application_details.check_guide"
size="large"
onClick={() => {
setIsReadmeOpen(true);
}}
/>
<Drawer isOpen={isReadmeOpen} onClose={onCloseDrawer}>
<GuideDrawer app={data} onClose={onCloseDrawer} />
</Drawer>
<ActionMenu
buttonProps={{ icon: <More className={styles.moreIcon} />, size: 'large' }}
title={t('general.more_options')}
>
<ActionMenuItem
icon={<Delete />}
type="danger"
onClick={() => {
setIsDeleteFormOpen(true);
}}
>
{t('general.delete')}
</ActionMenuItem>
</ActionMenu>
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
isLoading={isDeleting}
expectedInput={data.name}
inputPlaceholder={t('application_details.enter_your_application_name')}
className={styles.deleteConfirm}
onCancel={() => {
setIsDeleteFormOpen(false);
}}
onConfirm={onDelete}
>
<div className={styles.description}>
<Trans components={{ span: <span className={styles.highlight} /> }}>
{t('application_details.delete_description', { name: data.name })}
</Trans>
</div>
</DeleteConfirmModal>
</div>
</Card>
</DeleteConfirmModal>
<TabNav>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Settings}`}>
{t('application_details.settings')}

View file

@ -5,7 +5,6 @@
align-items: center;
font: var(--font-label-2);
gap: _.unit(2);
padding-right: _.unit(5);
> svg {
flex-shrink: 0;

View file

@ -15,19 +15,24 @@ import * as styles from './index.module.scss';
type Props = {
usage: number;
isCompact?: boolean;
};
function EmailUsage({ usage }: Props) {
function EmailUsage({ usage, isCompact }: Props) {
const theme = useTheme();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { getDocumentationUrl } = useDocumentationUrl();
return (
<div className={styles.container}>
{theme === Theme.Light ? <EmailSentIconLight /> : <EmailSentIconDark />}
<DynamicT
forKey="connector_details.logto_email.total_email_sent"
interpolation={{ value: usage }}
/>
{isCompact ? (
usage
) : (
<DynamicT
forKey="connector_details.logto_email.total_email_sent"
interpolation={{ value: usage }}
/>
)}
<ToggleTip
content={(closeTipHandler) => (
<Trans

View file

@ -1,77 +1,5 @@
@use '@/scss/underscore' as _;
.header {
padding: _.unit(6) _.unit(8);
display: flex;
align-items: center;
justify-content: space-between;
> *:not(:first-child) {
margin-left: _.unit(6);
}
.operations {
display: flex;
align-items: center;
.moreIcon {
color: var(--color-text-secondary);
}
> *:not(:first-child) {
margin-left: _.unit(2);
}
}
.metadata {
flex: 1;
> div {
display: flex;
align-items: center;
&:not(:first-child) {
margin-top: _.unit(2);
}
> *:not(:first-child) {
margin-left: _.unit(2);
}
}
.name {
font: var(--font-title-1);
color: var(--color-text);
}
.factoryName {
background: var(--color-surface-variant);
border-radius: 10px;
padding: _.unit(0.5) _.unit(2);
color: var(--color-text);
font: var(--font-label-3);
}
.text {
font: var(--font-label-2);
color: var(--color-text-secondary);
}
.verticalBar {
@include _.vertical-bar;
height: 12px;
}
}
}
.codeEditor {
margin-bottom: _.unit(6);
}
.resetIcon {
color: var(--color-text-secondary);
}
.readme {
padding: 0 _.unit(6);
margin: _.unit(6);

View file

@ -2,6 +2,7 @@ import { withAppInsights } from '@logto/app-insights/react';
import { ServiceConnector } from '@logto/connector-kit';
import { ConnectorType } from '@logto/schemas';
import type { ConnectorFactoryResponse, ConnectorResponse } from '@logto/schemas';
import { condArray, conditional } from '@silverhand/essentials';
import { useEffect, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
@ -9,23 +10,21 @@ import { useLocation, useParams } from 'react-router-dom';
import useSWR, { useSWRConfig } from 'swr';
import Delete from '@/assets/icons/delete.svg';
import More from '@/assets/icons/more.svg';
import File from '@/assets/icons/file.svg';
import Reset from '@/assets/icons/reset.svg';
import ConnectorLogo from '@/components/ConnectorLogo';
import CreateConnectorForm from '@/components/CreateConnectorForm';
import DeleteConnectorConfirmModal from '@/components/DeleteConnectorConfirmModal';
import DetailsPage from '@/components/DetailsPage';
import DetailsPageHeader, {
type MenuItem as ActionMenuItemItem,
} from '@/components/DetailsPage/DetailsPageHeader';
import Drawer from '@/components/Drawer';
import Markdown from '@/components/Markdown';
import PageMeta from '@/components/PageMeta';
import UnnamedTrans from '@/components/UnnamedTrans';
import { ConnectorsTabs } from '@/consts/page-tabs';
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
import Button from '@/ds-components/Button';
import Card from '@/ds-components/Card';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import Tag from '@/ds-components/Tag';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useConnectorApi from '@/hooks/use-connector-api';
@ -122,117 +121,89 @@ function ConnectorDetails() {
{isSocial && <ConnectorTabs target={data.target} connectorId={data.id} />}
{data && (
<>
<Card className={styles.header}>
<ConnectorLogo data={data} size="large" />
<div className={styles.metadata}>
<div>
<div className={styles.name}>
<UnnamedTrans resource={data.name} />
</div>
</div>
<div>
<ConnectorTypeName type={data.type} />
<div className={styles.verticalBar} />
{connectorFactory && (
<>
<Tag>
<UnnamedTrans resource={connectorFactory.name} />
</Tag>
<div className={styles.verticalBar} />
</>
)}
<Tag type="state" status={inUse ? 'success' : 'info'}>
{t(
inUse
? 'connectors.connector_status_in_use'
: 'connectors.connector_status_not_in_use'
)}
</Tag>
<div className={styles.verticalBar} />
<div className={styles.text}>ID</div>
<CopyToClipboard size="small" value={data.id} />
</div>
</div>
<div className={styles.operations}>
{data.type === ConnectorType.Email && data.usage !== undefined && (
<DetailsPageHeader
icon={<ConnectorLogo data={data} size="large" />}
title={<UnnamedTrans resource={data.name} />}
primaryTag={<ConnectorTypeName type={data.type} />}
statusTag={{
status: inUse ? 'success' : 'info',
text: inUse
? 'connectors.connector_status_in_use'
: 'connectors.connector_status_not_in_use',
}}
identifier={{ name: 'ID', value: data.id }}
additionalActionButton={conditional(
data.connectorId !== ServiceConnector.Email && {
title: 'connector_details.check_readme',
icon: <File />,
onClick: () => {
setIsReadMeOpen(true);
},
}
)}
additionalCustomElement={conditional(
data.type === ConnectorType.Email && data.usage !== undefined && (
<EmailUsage usage={data.usage} />
)}
{/* Note: hide the 'Check README' button for the email service connector since it's provided by Logto and no setup guide is needed */}
{data.connectorId !== ServiceConnector.Email && (
<>
<Button
title="connector_details.check_readme"
size="large"
onClick={() => {
setIsReadMeOpen(true);
}}
/>
<Drawer
title="connectors.title"
subtitle="connectors.subtitle"
isOpen={isReadMeOpen}
onClose={() => {
setIsReadMeOpen(false);
}}
>
<Markdown className={styles.readme}>{data.readme}</Markdown>
</Drawer>
</>
)}
<ActionMenu
buttonProps={{ icon: <More className={styles.moreIcon} />, size: 'large' }}
title={t('general.more_options')}
>
{!isSocial && (
<ActionMenuItem
icon={<Reset />}
iconClassName={styles.resetIcon}
onClick={() => {
setIsSetupOpen(true);
}}
>
{t(
)
)}
actionMenuItems={[
...condArray(
!isSocial && [
{
title:
data.type === ConnectorType.Sms
? 'connector_details.options_change_sms'
: 'connector_details.options_change_email'
)}
</ActionMenuItem>
)}
<ActionMenuItem
icon={<Delete />}
type="danger"
onClick={() => {
setIsDeleteAlertOpen(true);
}}
>
{t('general.delete')}
</ActionMenuItem>
</ActionMenu>
<CreateConnectorForm
isOpen={isSetupOpen}
type={data.type}
onClose={async (connectorId?: string) => {
setIsSetupOpen(false);
: 'connector_details.options_change_email',
icon: <Reset />,
onClick: () => {
setIsSetupOpen(true);
},
} satisfies ActionMenuItemItem,
]
),
{
type: 'danger',
title: 'general.delete',
icon: <Delete />,
onClick: () => {
setIsDeleteAlertOpen(true);
},
},
]}
/>
<Drawer
title="connectors.title"
subtitle="connectors.subtitle"
isOpen={isReadMeOpen}
onClose={() => {
setIsReadMeOpen(false);
}}
>
<Markdown className={styles.readme}>{data.readme}</Markdown>
</Drawer>
<CreateConnectorForm
isOpen={isSetupOpen}
type={data.type}
onClose={async (connectorId?: string) => {
setIsSetupOpen(false);
if (connectorId) {
/**
* Note:
* The "Email Service Connector" is a built-in connector that can be directly created without the need for setup in the guide.
*/
if (connectorId === ServiceConnector.Email) {
const created = await createConnector({ connectorId });
navigate(`/connectors/${ConnectorsTabs.Passwordless}/${created.id}`, {
replace: true,
});
return;
}
if (connectorId) {
/**
* Note:
* The "Email Service Connector" is a built-in connector that can be directly created without the need for setup in the guide.
*/
if (connectorId === ServiceConnector.Email) {
const created = await createConnector({ connectorId });
navigate(`/connectors/${ConnectorsTabs.Passwordless}/${created.id}`, {
replace: true,
});
return;
}
navigate(`${getConnectorsPathname(isSocial)}/guide/${connectorId}`);
}
}}
/>
</div>
</Card>
navigate(`${getConnectorsPathname(isSocial)}/guide/${connectorId}`);
}
}}
/>
<TabNav>
<TabNavItem href={`${getConnectorsPathname(isSocial)}/${connectorId}`}>
{t('general.settings_nav')}

View file

@ -3,56 +3,3 @@
.withTable {
height: 100%;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: _.unit(6) _.unit(8);
> *:not(:first-child) {
margin-left: _.unit(6);
}
.icon {
margin-left: _.unit(2);
width: 60px;
height: 60px;
}
.info {
flex: 1;
.name {
font: var(--font-title-1);
color: var(--color-text);
}
.meta {
display: flex;
align-items: center;
> * {
display: inline-block;
}
> :not(:first-child) {
margin-left: _.unit(2);
}
.idText {
font: var(--font-label-2);
color: var(--color-text-secondary);
}
.verticalBar {
@include _.vertical-bar;
height: 12px;
}
}
}
.moreIcon {
color: var(--color-text-secondary);
}
}

View file

@ -11,17 +11,13 @@ import useSWR, { useSWRConfig } from 'swr';
import Delete from '@/assets/icons/delete.svg';
import MachineToMachineRoleIconDark from '@/assets/icons/m2m-role-dark.svg';
import MachineToMachineRoleIcon from '@/assets/icons/m2m-role.svg';
import More from '@/assets/icons/more.svg';
import UserRoleIconDark from '@/assets/icons/user-role-dark.svg';
import UserRoleIcon from '@/assets/icons/user-role.svg';
import DetailsPage from '@/components/DetailsPage';
import DetailsPageHeader from '@/components/DetailsPage/DetailsPageHeader';
import { RoleDetailsTabs } from '@/consts/page-tabs';
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
import Card from '@/ds-components/Card';
import ConfirmModal from '@/ds-components/ConfirmModal';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import Tag from '@/ds-components/Tag';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useTenantPathname from '@/hooks/use-tenant-pathname';
@ -30,20 +26,9 @@ import useTheme from '@/hooks/use-theme';
import * as styles from './index.module.scss';
import { type RoleDetailsOutletContext } from './types';
const getRoleIcon = (type: RoleType, isDarkMode: boolean) => {
if (type === RoleType.User) {
return isDarkMode ? (
<UserRoleIconDark className={styles.icon} />
) : (
<UserRoleIcon className={styles.icon} />
);
}
return isDarkMode ? (
<MachineToMachineRoleIconDark className={styles.icon} />
) : (
<MachineToMachineRoleIcon className={styles.icon} />
);
const icons = {
[Theme.Light]: { UserIcon: UserRoleIcon, MachineToMachineIcon: MachineToMachineRoleIcon },
[Theme.Dark]: { UserIcon: UserRoleIconDark, MachineToMachineIcon: MachineToMachineRoleIconDark },
};
function RoleDetails() {
@ -52,6 +37,7 @@ function RoleDetails() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { navigate } = useTenantPathname();
const theme = useTheme();
const { UserIcon, MachineToMachineIcon } = icons[theme];
const isPageHasTable =
pathname.endsWith(RoleDetailsTabs.Permissions) ||
@ -100,49 +86,37 @@ function RoleDetails() {
>
{data && (
<>
<Card className={styles.header}>
{getRoleIcon(data.type, theme === Theme.Dark)}
<div className={styles.info}>
<div className={styles.name}>{data.name}</div>
<div className={styles.meta}>
<Tag>
{t(
data.type === RoleType.User
? 'role_details.type_user_role_tag'
: 'role_details.type_m2m_role_tag'
)}
</Tag>
<div className={styles.verticalBar} />
<div className={styles.idText}>{t('role_details.identifier')}</div>
<CopyToClipboard value={data.id} size="small" />
</div>
</div>
<ActionMenu
buttonProps={{ icon: <More className={styles.moreIcon} />, size: 'large' }}
title={t('general.more_options')}
>
<ActionMenuItem
icon={<Delete />}
type="danger"
onClick={() => {
<DetailsPageHeader
icon={data.type === RoleType.User ? <UserIcon /> : <MachineToMachineIcon />}
title={data.name}
primaryTag={t(
data.type === RoleType.User
? 'role_details.type_user_role_tag'
: 'role_details.type_m2m_role_tag'
)}
identifier={{ name: 'ID', value: data.id }}
actionMenuItems={[
{
title: 'general.delete',
icon: <Delete />,
type: 'danger',
onClick: () => {
setIsDeletionAlertOpen(true);
}}
>
{t('general.delete')}
</ActionMenuItem>
</ActionMenu>
<ConfirmModal
isOpen={isDeletionAlertOpen}
isLoading={isDeleting}
confirmButtonText="general.delete"
onCancel={() => {
setIsDeletionAlertOpen(false);
}}
onConfirm={handleDelete}
>
{t('role_details.delete_description')}
</ConfirmModal>
</Card>
},
},
]}
/>
<ConfirmModal
isOpen={isDeletionAlertOpen}
isLoading={isDeleting}
confirmButtonText="general.delete"
onCancel={() => {
setIsDeletionAlertOpen(false);
}}
onConfirm={handleDelete}
>
{t('role_details.delete_description')}
</ConfirmModal>
<TabNav>
<TabNavItem href={`/roles/${data.id}/${RoleDetailsTabs.Settings}`}>
{t('role_details.settings_tab')}

View file

@ -3,59 +3,3 @@
.resourceLayout {
height: 100%;
}
.header {
padding: _.unit(6) _.unit(8);
display: flex;
align-items: center;
justify-content: space-between;
> *:not(:first-child) {
margin-left: _.unit(6);
}
.metadata {
flex: 1;
> div {
display: flex;
align-items: center;
> *:not(:last-child) {
margin-right: _.unit(2);
}
}
.title {
font: var(--font-title-1);
color: var(--color-text);
}
.suspended {
background: var(--color-error-container);
color: var(--color-text);
font: var(--font-label-3);
padding: _.unit(0.5) _.unit(1.5);
border-radius: 10px;
}
.subtitle {
color: var(--color-text-secondary);
font: var(--font-label-2);
}
.text {
font: var(--font-label-2);
color: var(--color-text-secondary);
}
.verticalBar {
@include _.vertical-bar;
height: 12px;
}
}
}
.icon {
color: var(--color-text-secondary);
}

View file

@ -10,17 +10,14 @@ import useSWR from 'swr';
import Delete from '@/assets/icons/delete.svg';
import Forbidden from '@/assets/icons/forbidden.svg';
import More from '@/assets/icons/more.svg';
import Reset from '@/assets/icons/reset.svg';
import Shield from '@/assets/icons/shield.svg';
import DetailsPage from '@/components/DetailsPage';
import DetailsPageHeader from '@/components/DetailsPage/DetailsPageHeader';
import PageMeta from '@/components/PageMeta';
import UserAvatar from '@/components/UserAvatar';
import { UserDetailsTabs } from '@/consts/page-tabs';
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
import Card from '@/ds-components/Card';
import ConfirmModal from '@/ds-components/ConfirmModal';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import type { RequestError } from '@/hooks/use-api';
@ -112,106 +109,86 @@ function UserDetails() {
<PageMeta titleKey="user_details.page_title" />
{data && (
<>
<Card className={styles.header}>
<UserAvatar user={data} size="xlarge" />
<div className={styles.metadata}>
<div className={styles.title}>{getUserTitle(data)}</div>
<div>
{isSuspendedUser && <SuspendedTag />}
{userSubtitle && (
<>
<div className={styles.subtitle}>{userSubtitle}</div>
<div className={styles.verticalBar} />
</>
)}
<div className={styles.text}>User ID</div>
<CopyToClipboard size="small" value={data.id} />
</div>
</div>
<div>
<ActionMenu
buttonProps={{ icon: <More className={styles.icon} />, size: 'large' }}
title={t('general.more_options')}
>
<ActionMenuItem
icon={<Reset />}
iconClassName={styles.icon}
onClick={() => {
setIsResetPasswordFormOpen(true);
}}
>
{t('user_details.reset_password.reset_password')}
</ActionMenuItem>
<ActionMenuItem
icon={isSuspendedUser ? <Shield /> : <Forbidden />}
iconClassName={styles.icon}
onClick={() => {
setIsToggleSuspendFormOpen(true);
}}
>
{t(
isSuspendedUser ? 'user_details.reactivate_user' : 'user_details.suspend_user'
)}
</ActionMenuItem>
<ActionMenuItem
icon={<Delete />}
type="danger"
onClick={() => {
setIsDeleteFormOpen(true);
}}
>
{t('general.delete')}
</ActionMenuItem>
</ActionMenu>
<ReactModal
shouldCloseOnEsc
isOpen={isResetPasswordFormOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
setIsResetPasswordFormOpen(false);
}}
>
<ResetPasswordForm
userId={data.id}
onClose={(password) => {
setIsResetPasswordFormOpen(false);
<DetailsPageHeader
icon={<UserAvatar user={data} size="xlarge" />}
title={getUserTitle(data)}
subtitle={userSubtitle}
primaryTag={isSuspendedUser && <SuspendedTag />}
identifier={{ name: 'User ID', value: data.id }}
actionMenuItems={[
{
title: 'user_details.reset_password.reset_password',
icon: <Reset />,
onClick: () => {
setIsResetPasswordFormOpen(true);
},
},
{
title: isSuspendedUser
? 'user_details.reactivate_user'
: 'user_details.suspend_user',
icon: isSuspendedUser ? <Shield /> : <Forbidden />,
onClick: () => {
setIsToggleSuspendFormOpen(true);
},
},
{
title: 'general.delete',
type: 'danger',
icon: <Delete />,
onClick: () => {
setIsDeleteFormOpen(true);
},
},
]}
/>
<ReactModal
shouldCloseOnEsc
isOpen={isResetPasswordFormOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
setIsResetPasswordFormOpen(false);
}}
>
<ResetPasswordForm
userId={data.id}
onClose={(password) => {
setIsResetPasswordFormOpen(false);
if (password) {
setResetResult(password);
}
}}
/>
</ReactModal>
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
isLoading={isDeleting}
onCancel={() => {
setIsDeleteFormOpen(false);
}}
onConfirm={onDelete}
>
<div>{t('user_details.delete_description')}</div>
</DeleteConfirmModal>
<ConfirmModal
isOpen={isToggleSuspendFormOpen}
isLoading={isUpdatingSuspendState}
confirmButtonText={
isSuspendedUser ? 'user_details.reactivate_action' : 'user_details.suspend_action'
if (password) {
setResetResult(password);
}
onCancel={() => {
setIsToggleSuspendFormOpen(false);
}}
onConfirm={onToggleSuspendState}
>
{t(
isSuspendedUser
? 'user_details.reactivate_user_reminder'
: 'user_details.suspend_user_reminder'
)}
</ConfirmModal>
</div>
</Card>
}}
/>
</ReactModal>
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
isLoading={isDeleting}
onCancel={() => {
setIsDeleteFormOpen(false);
}}
onConfirm={onDelete}
>
<div>{t('user_details.delete_description')}</div>
</DeleteConfirmModal>
<ConfirmModal
isOpen={isToggleSuspendFormOpen}
isLoading={isUpdatingSuspendState}
confirmButtonText={
isSuspendedUser ? 'user_details.reactivate_action' : 'user_details.suspend_action'
}
onCancel={() => {
setIsToggleSuspendFormOpen(false);
}}
onConfirm={onToggleSuspendState}
>
{t(
isSuspendedUser
? 'user_details.reactivate_user_reminder'
: 'user_details.suspend_user_reminder'
)}
</ConfirmModal>
<TabNav>
<TabNavItem href={`/users/${data.id}/${UserDetailsTabs.Settings}`}>
{t('user_details.tab_settings')}

View file

@ -3,60 +3,3 @@
.containsTableLayout {
height: 100%;
}
.header {
padding: _.unit(6);
display: flex;
align-items: center;
justify-content: space-between;
.webhookIcon {
margin-left: _.unit(2);
width: 60px;
height: 60px;
}
> *:not(:first-child) {
margin-left: _.unit(6);
}
.metadata {
flex: 1;
> div {
display: flex;
align-items: center;
}
.title {
font: var(--font-title-1);
color: var(--color-text);
}
.text {
font: var(--font-label-2);
color: var(--color-text-secondary);
margin-right: _.unit(2);
}
.verticalBar {
@include _.vertical-bar;
height: 12px;
margin: 0 _.unit(2);
}
.state {
display: flex;
align-items: center;
font: var(--font-body-2);
.successRate {
margin-right: _.unit(1);
}
}
}
}
.icon {
color: var(--color-text-secondary);
}

View file

@ -1,5 +1,6 @@
import { withAppInsights } from '@logto/app-insights/react';
import { type HookResponse, type Hook } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
@ -9,20 +10,16 @@ import useSWR from 'swr';
import Delete from '@/assets/icons/delete.svg';
import Forbidden from '@/assets/icons/forbidden.svg';
import More from '@/assets/icons/more.svg';
import Shield from '@/assets/icons/shield.svg';
import WebhookDark from '@/assets/icons/webhook-dark.svg';
import Webhook from '@/assets/icons/webhook.svg';
import DetailsPage from '@/components/DetailsPage';
import DetailsPageHeader from '@/components/DetailsPage/DetailsPageHeader';
import PageMeta from '@/components/PageMeta';
import SuccessRate from '@/components/SuccessRate';
import { WebhookDetailsTabs } from '@/consts';
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
import Card from '@/ds-components/Card';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import DynamicT from '@/ds-components/DynamicT';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import Tag from '@/ds-components/Tag';
import useApi, { type RequestError } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useTenantPathname from '@/hooks/use-tenant-pathname';
@ -102,9 +99,7 @@ function WebhookDetails() {
.json<Hook>();
void mutate({ ...updatedHook, executionStats: data.executionStats });
const { enabled } = updatedHook;
toast.success(
t(enabled ? 'webhook_details.webhook_reactivated' : 'webhook_details.webhook_disabled')
);
toast.success(t(`webhook_details.webhook_${enabled ? 'reactivated' : 'disabled'}`));
} finally {
setIsUpdatingEnableState(false);
}
@ -122,63 +117,35 @@ function WebhookDetails() {
<PageMeta titleKey="webhook_details.page_title" />
{data && (
<>
<Card className={styles.header}>
<WebhookIcon className={styles.webhookIcon} />
<div className={styles.metadata}>
<div className={styles.title}>{data.name}</div>
<div>
{isEnabled && executionStats ? (
<div className={styles.state}>
{executionStats.requestCount > 0 && (
<>
<SuccessRate
className={styles.successRate}
successCount={executionStats.successCount}
totalCount={executionStats.requestCount}
/>
<DynamicT forKey="webhook_details.success_rate" />
<div className={styles.verticalBar} />
</>
)}
<DynamicT
forKey="webhook_details.requests"
interpolation={{ value: executionStats.requestCount }}
/>
</div>
) : (
<Tag type="state" status="info">
<DynamicT forKey="webhook_details.not_in_use" />
</Tag>
)}
<div className={styles.verticalBar} />
<div className={styles.text}>ID</div>
<CopyToClipboard size="small" value={data.id} />
</div>
</div>
<div>
<ActionMenu
buttonProps={{ icon: <More className={styles.icon} />, size: 'large' }}
title={t('general.more_options')}
>
<ActionMenuItem
icon={isEnabled ? <Forbidden /> : <Shield />}
iconClassName={styles.icon}
onClick={handleToggleEnableState}
>
<DynamicT
forKey={
isEnabled
? 'webhook_details.disable_webhook'
: 'webhook_details.reactivate_webhook'
}
/>
</ActionMenuItem>
<ActionMenuItem icon={<Delete />} type="danger" onClick={handleDelete}>
<DynamicT forKey="webhook_details.delete_webhook" />
</ActionMenuItem>
</ActionMenu>
</div>
</Card>
<DetailsPageHeader
icon={<WebhookIcon />}
title={data.name}
subtitle={
isEnabled &&
t('webhook_details.requests', { value: executionStats?.requestCount ?? 0 })
}
primaryTag={
isEnabled
? conditional(
!!executionStats?.successCount && <SuccessRate stats={executionStats} />
)
: t('webhook_details.not_in_use')
}
identifier={{ name: 'ID', value: data.id }}
actionMenuItems={[
{
title: `webhook_details.${isEnabled ? 'disable' : 'reactivate'}_webhook`,
icon: isEnabled ? <Forbidden /> : <Shield />,
onClick: handleToggleEnableState,
},
{
title: 'webhook_details.delete_webhook',
icon: <Delete />,
type: 'danger',
onClick: handleDelete,
},
]}
/>
<TabNav>
<TabNavItem href={`/webhooks/${data.id}/${WebhookDetailsTabs.Settings}`}>
<DynamicT forKey="webhook_details.settings_tab" />

View file

@ -105,9 +105,9 @@ function Webhooks() {
title: <DynamicT forKey="webhooks.table.success_rate" />,
dataIndex: 'successRate',
colSpan: 3,
render: ({ enabled, executionStats: { successCount, requestCount } }) => {
render: ({ enabled, executionStats }) => {
return enabled ? (
<SuccessRate successCount={successCount} totalCount={requestCount} />
<SuccessRate isNumberOnly stats={executionStats} />
) : (
<Tag type="state" status="info" variant="plain">
<DynamicT forKey="webhook_details.not_in_use" />

View file

@ -6,7 +6,7 @@
@mixin main-content-width {
max-width: 1168px;
min-width: 604px;
min-width: 560px;
margin: 0 auto;
}

View file

@ -81,7 +81,7 @@ describe('M2M RBAC', () => {
text: `The API resource ${apiResourceName} has been successfully created`,
});
await expect(page).toMatchElement('div[class$=header] div[class$=info] div', {
await expect(page).toMatchElement('div[class$=header] div[class$=metadata] div', {
text: apiResourceName,
});
});

View file

@ -60,7 +60,7 @@ export const createM2mRoleAndAssignPermissions = async (
await expectModalWithTitle(page, 'Assign apps');
await expectToClickModalAction(page, 'Skip for now');
await expect(page).toMatchElement('div[class$=header] div[class$=info] div[class$=name]', {
await expect(page).toMatchElement('div[class$=header] div[class$=metadata] div[class$=name]', {
text: roleName,
});
};

View file

@ -71,7 +71,7 @@ describe('RBAC', () => {
text: `The API resource ${apiResourceName} has been successfully created`,
});
await expect(page).toMatchElement('div[class$=header] div[class$=info] div', {
await expect(page).toMatchElement('div[class$=header] div[class$=metadata] div', {
text: apiResourceName,
});
});
@ -173,7 +173,7 @@ describe('RBAC', () => {
await expectModalWithTitle(page, 'Assign users');
await expectToClickModalAction(page, 'Skip for now');
await expect(page).toMatchElement('div[class$=header] div[class$=info] div', {
await expect(page).toMatchElement('div[class$=header] div[class$=metadata] div', {
text: roleName,
});
});
@ -264,7 +264,7 @@ describe('RBAC', () => {
text: rbacTestUsername,
});
await expect(page).toMatchElement('div[class$=header] div[class$=title]', {
await expect(page).toMatchElement('div[class$=header] div[class$=name]', {
text: rbacTestUsername,
});
@ -337,7 +337,7 @@ describe('RBAC', () => {
text: roleName,
});
await expect(page).toMatchElement('div[class$=header] div[class$=info] div', {
await expect(page).toMatchElement('div[class$=header] div[class$=metadata] div', {
text: roleName,
});
@ -370,7 +370,7 @@ describe('RBAC', () => {
text: apiResourceName,
});
await expect(page).toMatchElement('div[class$=header] div[class$=info] div', {
await expect(page).toMatchElement('div[class$=header] div[class$=metadata] div', {
text: apiResourceName,
});

View file

@ -26,6 +26,8 @@ import {
expectToResetSignUpAndSignInConfig,
} from './helpers.js';
await page.setViewport({ width: 1920, height: 1080 });
describe('sign-in experience(sad path): sign-up and sign-in', () => {
const logtoConsoleUrl = new URL(logtoConsoleUrlString);

View file

@ -50,11 +50,11 @@ describe('user management', () => {
// Go to user details page
await expectToClickModalAction(page, 'Check user detail');
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=name]', {
text: 'jdoe@gmail.com',
});
const userId = await page.$eval(
'div[class$=main] div[class$=metadata] div[class$=row] div:first-of-type',
'div[class$=main] div[class$=metadata] div[class$=row] div[class$=content]',
(element) => element.textContent
);
if (userId) {
@ -130,7 +130,7 @@ describe('user management', () => {
// Go to the user details page
await expectToClickModalAction(page, 'Check user detail');
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=name]', {
text: username,
});
@ -147,14 +147,14 @@ describe('user management', () => {
await expectToSaveChanges(page);
await waitForToast(page, { text: 'Saved' });
// Top userinfo card shows the updated user full name as the title
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=name]', {
text: newFullName,
});
await expect(page).toFillForm('form', { name: '' });
await expectToSaveChanges(page);
// After removing full name, top userinfo card shows the email as the title
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=name]', {
text: newEmail,
});
@ -162,7 +162,7 @@ describe('user management', () => {
await expect(page).toFillForm('form', { primaryEmail: '' });
await expectToSaveChanges(page);
// After removing email, top userinfo card shows the phone number as the title
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=name]', {
text: formatPhoneNumberToInternational(newPhone),
});
await page.waitForTimeout(500);
@ -170,7 +170,7 @@ describe('user management', () => {
await expect(page).toFillForm('form', { primaryPhone: '' });
await expectToSaveChanges(page);
// After removing phone number, top userinfo card shows the username as the title
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=name]', {
text: newUsername,
});
await page.waitForTimeout(500);
@ -183,7 +183,7 @@ describe('user management', () => {
});
await expectToClickModalAction(page, 'Confirm');
// After all identifiers, top userinfo card shows 'Unnamed' as the title
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=name]', {
text: 'Unnamed',
});
});

View file

@ -7,5 +7,5 @@ export const expectToCreateWebhook = async (page: Page) => {
await expect(page).toFill('input[name=name]', 'hook_name');
await expect(page).toFill('input[name=url]', 'https://localhost/webhook');
await expect(page).toClick('button[type=submit]');
await page.waitForSelector('div[class$=header] div[class$=metadata] div[class$=title]');
await page.waitForSelector('div[class$=header] div[class$=metadata] div[class$=name]');
};

View file

@ -34,12 +34,12 @@ describe('webhooks', () => {
await expectToCreateWebhook(page);
// Go to webhook details page
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=name]', {
text: 'hook_name',
});
const hookId = await page.$eval(
'div[class$=main] div[class$=metadata] div[class$=row] div:first-of-type',
'div[class$=main] div[class$=metadata] div[class$=row] div[class$=content]',
(element) => element.textContent
);
if (hookId) {
@ -68,7 +68,7 @@ describe('webhooks', () => {
await expect(page).toFill('input[name=name]', 'hook_name');
await expect(page).toFill('input[name=url]', 'http://localhost/webhook');
await expect(page).toClick('button[type=submit]');
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=name]', {
text: 'hook_name',
});
});
@ -107,7 +107,7 @@ describe('webhooks', () => {
// Wait for the active webhook state info to appear
await page.waitForSelector(
'div[class$=header] div[class$=metadata] div:nth-of-type(2) div[class$=state]'
'div[class$=header] div[class$=metadata] > div[class$=row] > div[class$=text]'
);
});

View file

@ -65,10 +65,7 @@ export const expectToDiscardChanges = async (page: Page) => {
};
export const expectToClickDetailsPageOption = async (page: Page, optionText: string) => {
await expect(page).toClick(
'div[class$=header] button[class$=withIcon]:last-of-type span[class$=icon]:has(svg)'
);
await expect(page).toClick('div[class$=header] div[class$=operations] div button span:has(svg)');
await expect(page).toMatchElement(
'.ReactModalPortal div[class$=dropdownContainer] div[class$=dropdownTitle]',
{