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:
parent
1da511ad55
commit
a976799621
35 changed files with 738 additions and 914 deletions
3
packages/console/src/assets/icons/file.svg
Normal file
3
packages/console/src/assets/icons/file.svg
Normal 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 |
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 _.unit(6) 0 _.unit(2);
|
||||
min-width: 636px;
|
||||
|
||||
> * {
|
||||
@include _.main-content-width;
|
||||
|
|
|
@ -43,10 +43,7 @@
|
|||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: _.unit(2);
|
||||
}
|
||||
gap: _.unit(2);
|
||||
}
|
||||
|
||||
.trailingIcon {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
font: var(--font-body-2);
|
||||
@include _.text-ellipsis;
|
||||
|
||||
.icon {
|
||||
width: 10px;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
align-items: center;
|
||||
font: var(--font-label-2);
|
||||
gap: _.unit(2);
|
||||
padding-right: _.unit(5);
|
||||
|
||||
> svg {
|
||||
flex-shrink: 0;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
@mixin main-content-width {
|
||||
max-width: 1168px;
|
||||
min-width: 604px;
|
||||
min-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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]');
|
||||
};
|
||||
|
|
|
@ -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]'
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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]',
|
||||
{
|
||||
|
|
Loading…
Add table
Reference in a new issue