diff --git a/packages/console/src/assets/icons/conical-flask.svg b/packages/console/src/assets/icons/conical-flask.svg new file mode 100644 index 000000000..2059208bd --- /dev/null +++ b/packages/console/src/assets/icons/conical-flask.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/console/src/components/SubmitFormChangesActionBar/index.tsx b/packages/console/src/components/SubmitFormChangesActionBar/index.tsx index 54b4a8082..140a887fb 100644 --- a/packages/console/src/components/SubmitFormChangesActionBar/index.tsx +++ b/packages/console/src/components/SubmitFormChangesActionBar/index.tsx @@ -1,3 +1,4 @@ +import { type AdminConsoleKey } from '@logto/phrases'; import classNames from 'classnames'; import Button from '@/ds-components/Button'; @@ -9,9 +10,16 @@ type Props = { isSubmitting: boolean; onSubmit: () => Promise; onDiscard: () => void; + confirmText?: AdminConsoleKey; }; -function SubmitFormChangesActionBar({ isOpen, isSubmitting, onSubmit, onDiscard }: Props) { +function SubmitFormChangesActionBar({ + isOpen, + isSubmitting, + confirmText = 'general.save_changes', + onSubmit, + onDiscard, +}: Props) { return (
@@ -27,7 +35,7 @@ function SubmitFormChangesActionBar({ isOpen, isSubmitting, onSubmit, onDiscard isLoading={isSubmitting} type="primary" size="medium" - title="general.save_changes" + title={confirmText} onClick={async () => onSubmit()} />
diff --git a/packages/console/src/containers/ConsoleContent/Sidebar/hook.tsx b/packages/console/src/containers/ConsoleContent/Sidebar/hook.tsx index 5c09fa01f..064cb034b 100644 --- a/packages/console/src/containers/ConsoleContent/Sidebar/hook.tsx +++ b/packages/console/src/containers/ConsoleContent/Sidebar/hook.tsx @@ -129,7 +129,7 @@ export const useSidebarMenuItems = (): { { Icon: JwtClaims, title: 'customize_jwt', - isHidden: !isDevFeaturesEnabled, + isHidden: !(isCloud && isDevFeaturesEnabled), }, { Icon: Hook, diff --git a/packages/console/src/containers/ConsoleContent/index.tsx b/packages/console/src/containers/ConsoleContent/index.tsx index 2bf92992e..f78aefd00 100644 --- a/packages/console/src/containers/ConsoleContent/index.tsx +++ b/packages/console/src/containers/ConsoleContent/index.tsx @@ -243,7 +243,7 @@ function ConsoleContent() { )} {isCloud && isDevFeaturesEnabled && ( - + } /> } /> diff --git a/packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/ActionButton/CodeRestoreButton.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/ActionButton/CodeRestoreButton.tsx similarity index 100% rename from packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/ActionButton/CodeRestoreButton.tsx rename to packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/ActionButton/CodeRestoreButton.tsx diff --git a/packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/ActionButton/index.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/ActionButton/index.tsx similarity index 100% rename from packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/ActionButton/index.tsx rename to packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/ActionButton/index.tsx diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/Dashboard/index.module.scss b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/Dashboard/index.module.scss new file mode 100644 index 000000000..2dee6b105 --- /dev/null +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/Dashboard/index.module.scss @@ -0,0 +1,39 @@ +@use '@/scss/underscore' as _; + +.dashboard { + background-color: var(--color-code-bg-float); + border-top: 1px solid var(--color-code-dark-bg-focused); + font-family: 'Roboto Mono', monospace; + overflow: auto; + flex: 1; + + .dashboardHeader { + padding: _.unit(3) _.unit(4); + display: flex; + justify-content: space-between; + align-items: center; + font: var(--font-label-2); + font-family: 'Roboto Mono', monospace; + color: var(--color-code-white); + } + + .dashboardContent { + padding: _.unit(2) _.unit(4); + font: var(--font-body-2); + overflow: auto; + color: var(--color-code-white); + + pre { + white-space: pre-wrap; + + &.error { + color: var(--color-error); + } + + &:first-child { + margin-top: 0; + } + } + } +} + diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/Dashboard/index.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/Dashboard/index.tsx new file mode 100644 index 000000000..54ea03bf2 --- /dev/null +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/Dashboard/index.tsx @@ -0,0 +1,30 @@ +import classNames from 'classnames'; +import { type ReactNode } from 'react'; + +import CloseIcon from '@/assets/icons/close.svg'; +import IconButton from '@/ds-components/IconButton'; + +import * as styles from './index.module.scss'; + +export type Props = { + title: string; + content: ReactNode; + className?: string; + onClose: () => void; +}; + +function Dashboard({ title, content, className, onClose }: Props) { + return ( +
+
+ {title} + + + +
+
{content}
+
+ ); +} + +export default Dashboard; diff --git a/packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/config.ts b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/config.ts similarity index 82% rename from packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/config.ts rename to packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/config.ts index fbf03cecb..075776f7b 100644 --- a/packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/config.ts +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/config.ts @@ -7,6 +7,13 @@ export const logtoDarkTheme: IStandaloneThemeData = { base: 'vs-dark', inherit: true, rules: [], + colors: { + 'editor.background': '#090613', // :token/code/code-bg + }, +}; + +export const logtoLightTheme: IStandaloneThemeData = { + ...logtoDarkTheme, colors: { 'editor.background': '#181133', // :token/code/code-bg }, diff --git a/packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/index.module.scss b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/index.module.scss similarity index 86% rename from packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/index.module.scss rename to packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/index.module.scss index 54c622df8..b53083ef8 100644 --- a/packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/index.module.scss +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/index.module.scss @@ -1,9 +1,6 @@ @use '@/scss/underscore' as _; -@use '@/scss/code-editor' as codeEditor; .codeEditor { - @include codeEditor.color; - @include codeEditor.font; position: relative; display: flex; flex-direction: column; @@ -37,7 +34,7 @@ &.active, &:hover { color: var(--color-code-white); - background-color: var(--color-code-tab-active-bg); + background-color: var(--color-code-dark-bg-focused); border-radius: 8px; } } @@ -54,5 +51,10 @@ .editorContainer { position: relative; flex-grow: 1; + + &.dashboardOpen { + flex-grow: 0; + height: 50%; + } } } diff --git a/packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/index.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/index.tsx similarity index 74% rename from packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/index.tsx rename to packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/index.tsx index 377810106..2f0bd1793 100644 --- a/packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/index.tsx +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/index.tsx @@ -1,17 +1,21 @@ -import { Editor, useMonaco, type BeforeMount, type OnMount } from '@monaco-editor/react'; +import { Theme } from '@logto/schemas'; +import { Editor, useMonaco, type OnMount } from '@monaco-editor/react'; import { type Nullable } from '@silverhand/essentials'; import classNames from 'classnames'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react'; import CopyToClipboard from '@/ds-components/CopyToClipboard'; +import useTheme from '@/hooks/use-theme'; import { onKeyDownHandler } from '@/utils/a11y'; import CodeRestoreButton from './ActionButton/CodeRestoreButton.js'; -import { defaultOptions, logtoDarkTheme } from './config.js'; +import DashBoard, { type Props as DashboardProps } from './Dashboard'; +import { defaultOptions, logtoDarkTheme, logtoLightTheme } from './config.js'; import * as styles from './index.module.scss'; import type { IStandaloneCodeEditor, ModelSettings } from './type.js'; import useEditorHeight from './use-editor-height.js'; +export type { Props as DashboardProps } from './Dashboard'; export type { ModelControl, ModelSettings } from './type.js'; type ActionButtonType = 'restore' | 'copy'; @@ -26,6 +30,8 @@ type Props = { environmentVariablesDefinition?: string; onChange?: (value: string | undefined) => void; onMountHandler?: (editor: IStandaloneCodeEditor) => void; + actionButtons?: ReactNode; + dashboard?: DashboardProps; }; /** * Monaco code editor component. @@ -38,6 +44,8 @@ type Props = { * @param {string} prop.value - The value of the code editor for the current active model. * @param {(value: string | undefined) => void} prop.onChange - The callback function to handle the value change of the code editor. * @param {string} [prop.environmentVariablesDefinition] - The environment variables type definition for the script section. + * @param {ReactNode} [actionButtons] - Additional action buttons shown on the header + * @param {DashboardProps} [dashboard] - The dashboard component shown at the bottom of the editor. * * @returns */ @@ -51,9 +59,12 @@ function MonacoCodeEditor({ setActiveModel, onChange, onMountHandler, + actionButtons, + dashboard, }: Props) { const monaco = useMonaco(); const editorRef = useRef>(null); + const theme = useTheme(); const activeModel = useMemo( () => activeModelName && models.find((model) => model.name === activeModelName), @@ -63,8 +74,9 @@ function MonacoCodeEditor({ const isMultiModals = useMemo(() => models.length > 1, [models]); // Get the container ref and the editor height - const { containerRef, headerRef, editorHeight } = useEditorHeight(); + const { containerRef, editorHeight } = useEditorHeight(); + // Handle editor extraLibs and language compile settings useEffect(() => { // Monaco will be ready after the editor is mounted, useEffect will be called after the monaco is ready if (!monaco || !activeModel) { @@ -84,18 +96,27 @@ function MonacoCodeEditor({ 'environmentVariables.d.ts' ); } + + if (activeModel.language === 'typescript') { + // Set the typescript compiler options + monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ + allowNonTsExtensions: true, + strictNullChecks: true, + }); + } }, [activeModel, monaco, environmentVariablesDefinition]); - const handleEditorWillMount = useCallback((monaco) => { - // Register the new logto theme - monaco.editor.defineTheme('logto-dark', logtoDarkTheme); + // Handle the editor theme settings + useEffect(() => { + // Monaco will be ready after the editor is mounted, useEffect will be called after the monaco is ready + if (!monaco) { + return; + } - // Set the typescript compiler options - monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ - allowNonTsExtensions: true, - strictNullChecks: true, - }); - }, []); + const editorTheme = theme === Theme.Light ? logtoLightTheme : logtoDarkTheme; + + monaco.editor.defineTheme('logto-dark', editorTheme); + }, [monaco, theme]); const handleEditorDidMount = useCallback( (editor) => { @@ -107,8 +128,8 @@ function MonacoCodeEditor({ ); return ( -
-
+
+
{models.map(({ name, title, icon }) => (
{ - if (activeModel) { + if (activeModel && value !== activeModel.defaultValue) { onChange?.(activeModel.defaultValue); } }} @@ -147,9 +168,13 @@ function MonacoCodeEditor({ {enabledActions.includes('copy') && ( )} + {actionButtons}
-
+
{activeModel && ( )}
+ {dashboard && }
); } diff --git a/packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/type.ts b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/type.ts similarity index 100% rename from packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/type.ts rename to packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/type.ts diff --git a/packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/use-editor-height.ts b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/use-editor-height.ts similarity index 74% rename from packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/use-editor-height.ts rename to packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/use-editor-height.ts index 1c75d1694..b3a1045fc 100644 --- a/packages/console/src/pages/CustomizeJwtDetails/MonacoCodeEditor/use-editor-height.ts +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor/use-editor-height.ts @@ -1,23 +1,18 @@ -import { useRef, useState, useLayoutEffect } from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; // Recalculate the height of the editor when the container size changes // This is to avoid the code editor's height shaking when the content is updated. // @see {@link https://github.com/react-monaco-editor/react-monaco-editor/issues/391} const useEditorHeight = () => { const containerRef = useRef(null); - const headerRef = useRef(null); const [editorHeight, setEditorHeight] = useState('100%'); const safeArea = 16; useLayoutEffect(() => { const handleResize = () => { - const safeAreaHeight = headerRef.current?.clientHeight - ? headerRef.current.clientHeight + safeArea - : safeArea; - if (containerRef.current) { - setEditorHeight(containerRef.current.clientHeight - safeAreaHeight); + setEditorHeight(containerRef.current.clientHeight - safeArea); } }; @@ -34,7 +29,7 @@ const useEditorHeight = () => { }; }, []); - return { containerRef, headerRef, editorHeight }; + return { containerRef, editorHeight }; }; export default useEditorHeight; diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/ErrorContent/index.module.scss b/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/ErrorContent/index.module.scss new file mode 100644 index 000000000..d9f27a639 --- /dev/null +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/ErrorContent/index.module.scss @@ -0,0 +1,6 @@ +@use '@/scss/underscore' as _; + +.error { + margin: _.unit(4) 0; + color: var(--color-error-60); +} diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/ErrorContent/index.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/ErrorContent/index.tsx new file mode 100644 index 000000000..5fdb26658 --- /dev/null +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/ErrorContent/index.tsx @@ -0,0 +1,28 @@ +import { type TestResultData } from '@/pages/CustomizeJwtDetails/MainContent/ScriptSection/use-test-handler'; + +import * as styles from './index.module.scss'; + +type Props = { + testResult: TestResultData; +}; + +function ErrorContent({ testResult }: Props) { + return ( +
+ {testResult.error && ( +
+          {'Error: \n'}
+          {testResult.error}
+        
+ )} + {testResult.payload && ( +
+          {'JWT Payload: \n'}
+          {testResult.payload}
+        
+ )} +
+ ); +} + +export default ErrorContent; diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/index.module.scss b/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/index.module.scss index fd959ef67..0e6e77891 100644 --- a/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/index.module.scss +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/index.module.scss @@ -1,17 +1,18 @@ @use '@/scss/underscore' as _; - -.codePanel { +.scripeSection { position: relative; - display: flex; - flex-direction: column; + min-width: 50%; - .cardTitle { - font: var(--font-label-2); - margin-bottom: _.unit(3); + .fixHeightWrapper { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; } - .flexGrow { + .codeEditor { + min-height: 200px; flex-grow: 1; } } diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/index.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/index.tsx index d1d488f8e..421fdc552 100644 --- a/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/index.tsx +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/index.tsx @@ -1,10 +1,17 @@ /* Code Editor for the custom JWT claims script. */ import { LogtoJwtTokenPath } from '@logto/schemas'; +import classNames from 'classnames'; import { useCallback, useContext, useMemo } from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import RunIcon from '@/assets/icons/start.svg'; +import Button from '@/ds-components/Button'; import { CodeEditorLoadingContext } from '@/pages/CustomizeJwtDetails/CodeEditorLoadingContext'; -import MonacoCodeEditor, { type ModelSettings } from '@/pages/CustomizeJwtDetails/MonacoCodeEditor'; +import MonacoCodeEditor, { + type DashboardProps, + type ModelSettings, +} from '@/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor'; import { type JwtCustomizerForm } from '@/pages/CustomizeJwtDetails/type'; import { accessTokenJwtCustomizerModel, @@ -12,9 +19,12 @@ import { } from '@/pages/CustomizeJwtDetails/utils/config'; import { buildEnvironmentVariablesTypeDefinition } from '@/pages/CustomizeJwtDetails/utils/type-definitions'; +import ErrorContent from './ErrorContent'; import * as styles from './index.module.scss'; +import useTestHandler from './use-test-handler'; function ScriptSection() { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { watch, control } = useFormContext(); const tokenType = watch('tokenType'); @@ -46,35 +56,64 @@ function ScriptSection() { setIsMonacoLoaded(true); }, [setIsMonacoLoaded]); - return ( - ( - { - // If the value is the same as the default code and the original form script value is undefined, reset the value to undefined as well - if (newValue === activeModel.defaultValue && !defaultValues?.script) { - onChange(''); - return; - } + // Test handler + const { onTestHandler, setTestResult, isLoading, testResult } = useTestHandler(); - // Input value should not be undefined for react-hook-form @see https://react-hook-form.com/docs/usecontroller/controller - onChange(newValue ?? ''); - }} - onMountHandler={onMountHandler} + const dashBoardProps = useMemo(() => { + if (!testResult) { + return; + } + + return { + title: t('jwt_claims.tester.result_title'), + content: , + onClose: () => { + setTestResult(undefined); + }, + }; + }, [setTestResult, t, testResult]); + + return ( +
+
+ ( + } + size="small" + title="jwt_claims.tester.run_button" + type="primary" + isLoading={isLoading} + onClick={onTestHandler} + /> + } + dashboard={dashBoardProps} + onChange={(newValue) => { + // If the value is the same as the default code and the original form script value is undefined, reset the value to undefined as well + if (newValue === activeModel.defaultValue && !defaultValues?.script) { + onChange(''); + return; + } + + // Input value should not be undefined for react-hook-form @see https://react-hook-form.com/docs/usecontroller/controller + onChange(newValue ?? ''); + }} + onMountHandler={onMountHandler} + /> + )} /> - )} - /> +
+
); } diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/use-test-handler.ts b/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/use-test-handler.ts new file mode 100644 index 000000000..64a7a51d0 --- /dev/null +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/ScriptSection/use-test-handler.ts @@ -0,0 +1,65 @@ +import { type JsonObject, type RequestErrorBody } from '@logto/schemas'; +import { HTTPError } from 'ky'; +import { useCallback, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { z } from 'zod'; + +import useApi from '@/hooks/use-api'; +import { type JwtCustomizerForm } from '@/pages/CustomizeJwtDetails/type'; +import { formatFormDataToTestRequestPayload } from '@/pages/CustomizeJwtDetails/utils/format'; + +const testEndpointPath = 'api/configs/jwt-customizer/test'; +const jwtCustomizerGeneralErrorCode = 'jwt_customizer.general'; + +export type TestResultData = { + error?: string; + payload?: string; +}; + +const useTestHandler = () => { + const [testResult, setTestResult] = useState(); + const [isLoading, setIsLoading] = useState(false); + const { getValues } = useFormContext(); + const api = useApi({ hideErrorToast: true }); + + const onTestHandler = useCallback(async () => { + const payload = getValues(); + setIsLoading(true); + + const result = await api + .post(testEndpointPath, { + json: formatFormDataToTestRequestPayload(payload), + }) + .json() + .catch(async (error: unknown) => { + if (error instanceof HTTPError) { + const { response } = error; + const metadata = await response.clone().json(); + if (metadata.code === jwtCustomizerGeneralErrorCode) { + const result = z.object({ message: z.string() }).safeParse(metadata.data); + if (result.success) { + setTestResult({ + error: result.data.message, + }); + return; + } + } + } + + setTestResult({ + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + setIsLoading(false); + }); + + if (result) { + setTestResult({ payload: JSON.stringify(result, null, 2) }); + } + }, [api, getValues]); + + return { testResult, isLoading, onTestHandler, setTestResult }; +}; + +export default useTestHandler; diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/EnvironmentVariablesField.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/EnvironmentVariablesField.tsx similarity index 97% rename from packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/EnvironmentVariablesField.tsx rename to packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/EnvironmentVariablesField.tsx index 31ccfdf09..9d06a3486 100644 --- a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/EnvironmentVariablesField.tsx +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/EnvironmentVariablesField.tsx @@ -4,8 +4,7 @@ import { useTranslation } from 'react-i18next'; import FormField from '@/ds-components/FormField'; import KeyValueInputField from '@/ds-components/KeyValueInputField'; - -import { type JwtCustomizerForm } from '../../type'; +import { type JwtCustomizerForm } from '@/pages/CustomizeJwtDetails/type'; const isValidKey = (key: string) => { return /^\w+$/.test(key); diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/GuideCard/index.module.scss b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/GuideCard/index.module.scss new file mode 100644 index 000000000..0f35d1b7f --- /dev/null +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/GuideCard/index.module.scss @@ -0,0 +1,60 @@ +@use '@/scss/underscore' as _; + +.card { + .headerRow { + display: flex; + flex-direction: row; + gap: _.unit(4); + align-items: center; + cursor: pointer; + user-select: none; + } + + .cardHeader { + flex: 1; + } + + .cardTitle { + font: var(--font-label-2); + color: var(--color-text); + margin-bottom: _.unit(1); + } + + .cardSubtitle { + font: var(--font-body-2); + color: var(--color-text-secondary); + } + + .cardContent { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; + + // Collapsible content should be hidden by default, margin space can only be set at the child level + > *:first-child { + margin-top: _.unit(6); + } + + > *:not(:last-child) { + margin-bottom: _.unit(4); + } + } + + .expandButton { + width: 24px; + height: 24px; + transition: transform 0.3s ease; + color: var(--color-text-secondary); + } + + &.expanded { + .expandButton { + transform: rotate(180deg); + } + + .cardContent { + max-height: 1000; + overflow: visible; + } + } +} diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/GuideCard.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/GuideCard/index.tsx similarity index 78% rename from packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/GuideCard.tsx rename to packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/GuideCard/index.tsx index 21cdf806b..b432c4916 100644 --- a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/GuideCard.tsx +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/GuideCard/index.tsx @@ -1,5 +1,4 @@ import classNames from 'classnames'; -import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import CaretExpandedIcon from '@/assets/icons/caret-expanded.svg'; @@ -18,23 +17,24 @@ export enum CardType { type GuardCardProps = { name: CardType; children?: React.ReactNode; + isExpanded: boolean; + setExpanded: (expanded: boolean) => void; }; -function GuideCard({ name, children }: GuardCardProps) { - const [expanded, setExpanded] = useState(false); +function GuideCard({ name, children, isExpanded, setExpanded }: GuardCardProps) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.jwt_claims' }); return ( - +
{ - setExpanded((expanded) => !expanded); + setExpanded(!isExpanded); }} onKeyDown={onKeyDownHandler(() => { - setExpanded((expanded) => !expanded); + setExpanded(!isExpanded); })} >
diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/index.module.scss b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/index.module.scss new file mode 100644 index 000000000..325ceb4f2 --- /dev/null +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/index.module.scss @@ -0,0 +1,30 @@ +@use '@/scss/underscore' as _; + + +.sampleCode { + :global { + /* stylelint-disable-next-line selector-class-pattern */ + .monaco-editor { + border-radius: 8px; + + /* stylelint-disable-next-line selector-class-pattern */ + .overflow-guard { + border-radius: 8px; + } + + /* stylelint-disable-next-line selector-class-pattern */ + .lines-content { + padding: 0 16px; + } + } + } +} + +.envVariablesField { + margin-bottom: _.unit(4); +} + +.description { + font: var(--font-body-2); + color: var(--color-text-secondary); +} diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/index.tsx similarity index 61% rename from packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab.tsx rename to packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/index.tsx index 475b5a011..bab6c286c 100644 --- a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab.tsx +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/index.tsx @@ -1,21 +1,24 @@ import { LogtoJwtTokenPath } from '@logto/schemas'; import { Editor } from '@monaco-editor/react'; import classNames from 'classnames'; +import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { type JwtCustomizerForm } from '../../type'; +import { type JwtCustomizerForm } from '@/pages/CustomizeJwtDetails/type'; import { environmentVariablesCodeExample, fetchExternalDataCodeExample, sampleCodeEditorOptions, typeDefinitionCodeEditorOptions, -} from '../../utils/config'; +} from '@/pages/CustomizeJwtDetails/utils/config'; import { accessTokenPayloadTypeDefinition, clientCredentialsPayloadTypeDefinition, jwtCustomizerUserContextTypeDefinition, -} from '../../utils/type-definitions'; +} from '@/pages/CustomizeJwtDetails/utils/type-definitions'; + +import * as tabContentStyles from '../index.module.scss'; import EnvironmentVariablesField from './EnvironmentVariablesField'; import GuideCard, { CardType } from './GuideCard'; @@ -28,15 +31,22 @@ type Props = { /* Instructions and environment variable settings for the custom JWT claims script. */ function InstructionTab({ isActive }: Props) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const [expendCard, setExpendCard] = useState(); const { watch } = useFormContext(); const tokenType = watch('tokenType'); return ( -
-
{t('jwt_claims.jwt_claims_description')}
+
+
{t('jwt_claims.jwt_claims_description')}
{tokenType === LogtoJwtTokenPath.AccessToken && ( - + { + setExpendCard(expand ? CardType.UserData : undefined); + }} + > )} - + { + setExpendCard(expand ? CardType.TokenData : undefined); + }} + > - + { + setExpendCard(expand ? CardType.FetchExternalData : undefined); + }} + >
{t('jwt_claims.fetch_external_data.description')}
- - {/** - * We use useFieldArray hook to manage the list of environment variables in the EnvironmentVariablesField component. - * useFieldArray will read the form context and return the necessary methods and values to manage the list. - * The form context will mutate when the tokenType changes. It will provide different form state and methods based on the tokenType. (@see JwtClaims component.) - * However, the form context/controller updates did not trigger a re-render of the useFieldArray hook. (@see {@link https://github.com/react-hook-form/react-hook-form/blob/master/src/useFieldArray.ts#L95}) - * - * This cause issues when the tokenType changes and the environment variables list is not rerendered. The form state will be stale. - * In order to fix this, we need to re-render the EnvironmentVariablesField component when the tokenType changes. - * Achieve this by adding a key to the EnvironmentVariablesField component. Force a re-render when the tokenType changes. - */} - + { + setExpendCard(expand ? CardType.EnvironmentVariables : undefined); + }} + > +
{t('jwt_claims.environment_variables.sample_code')}
diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/TestResult.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/TestResult.tsx deleted file mode 100644 index 033eb760c..000000000 --- a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/TestResult.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useTranslation } from 'react-i18next'; - -import CloseIcon from '@/assets/icons/close.svg'; -import IconButton from '@/ds-components/IconButton'; - -import * as styles from './index.module.scss'; - -export type TestResultData = { - error?: string; - payload?: string; -}; - -type Props = { - testResult: TestResultData; - onClose: () => void; -}; - -function TestResult({ testResult, onClose }: Props) { - const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.jwt_claims' }); - - return ( -
-
- {t('tester.result_title')} - - - -
-
- {testResult.error && ( -
-            {'Error: \n'}
-            {testResult.error}
-          
- )} - {testResult.payload && ( -
-            {'JWT Payload: \n'}
-            {testResult.payload}
-          
- )} -
-
- ); -} - -export default TestResult; diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/TestTab.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/TestTab.tsx deleted file mode 100644 index 931be97a0..000000000 --- a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/TestTab.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { LogtoJwtTokenPath, type JsonObject, type RequestErrorBody } from '@logto/schemas'; -import { conditional } from '@silverhand/essentials'; -import classNames from 'classnames'; -import { HTTPError } from 'ky'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Controller, useFormContext, type ControllerRenderProps } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; - -import Button from '@/ds-components/Button'; -import Card from '@/ds-components/Card'; -import useApi from '@/hooks/use-api'; - -import MonacoCodeEditor, { type ModelControl, type ModelSettings } from '../../MonacoCodeEditor'; -import { type JwtCustomizerForm } from '../../type'; -import { - accessTokenPayloadTestModel, - clientCredentialsPayloadTestModel, - userContextTestModel, -} from '../../utils/config'; -import { formatFormDataToTestRequestPayload } from '../../utils/format'; - -import TestResult, { type TestResultData } from './TestResult'; -import * as styles from './index.module.scss'; - -type Props = { - isActive: boolean; -}; - -const accessTokenModelSettings = [accessTokenPayloadTestModel, userContextTestModel]; -const clientCredentialsModelSettings = [clientCredentialsPayloadTestModel]; -const testEndpointPath = 'api/configs/jwt-customizer/test'; -const jwtCustomizerGeneralErrorCode = 'jwt_customizer.general'; -/** - * SampleCode form filed value update formatter. - * Reset the field to undefined if the value is the same as the default value - */ -const updateSampleCodeValue = (model: ModelSettings, newValue: string | undefined) => { - return newValue === model.defaultValue ? undefined : newValue; -}; - -function TestTab({ isActive }: Props) { - const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.jwt_claims' }); - const [testResult, setTestResult] = useState(); - const [activeModelName, setActiveModelName] = useState(); - const api = useApi({ hideErrorToast: true }); - - const { watch, control, formState, getValues } = useFormContext(); - const tokenType = watch('tokenType'); - - const editorModels = useMemo( - () => - tokenType === LogtoJwtTokenPath.AccessToken - ? accessTokenModelSettings - : clientCredentialsModelSettings, - [tokenType] - ); - - useEffect(() => { - setActiveModelName(editorModels[0]?.name); - }, [editorModels, tokenType]); - - // Clear the test result when the token type changes - useEffect(() => { - setTestResult(undefined); - }, [tokenType]); - - const onTestHandler = useCallback(async () => { - const payload = getValues(); - - const result = await api - .post(testEndpointPath, { - json: formatFormDataToTestRequestPayload(payload), - }) - .json() - .catch(async (error: unknown) => { - if (error instanceof HTTPError) { - const { response } = error; - const metadata = await response.clone().json(); - if (metadata.code === jwtCustomizerGeneralErrorCode) { - const result = z.object({ message: z.string() }).safeParse(metadata.data); - if (result.success) { - setTestResult({ - error: result.data.message, - }); - return; - } - } - } - - setTestResult({ - error: error instanceof Error ? error.message : String(error), - }); - }); - - if (result) { - setTestResult({ payload: JSON.stringify(result, null, 2) }); - } - }, [api, getValues]); - - const getModelControllerProps = useCallback( - ({ value, onChange }: ControllerRenderProps): ModelControl => { - return { - value: - activeModelName === userContextTestModel.name ? value.contextSample : value.tokenSample, - onChange: (newValue: string | undefined) => { - // Form value is a object we need to update the specific field - const updatedValue: JwtCustomizerForm['testSample'] = { - ...value, - ...conditional( - activeModelName === userContextTestModel.name && { - contextSample: updateSampleCodeValue(userContextTestModel, newValue), - } - ), - ...conditional( - activeModelName === accessTokenPayloadTestModel.name && { - tokenSample: updateSampleCodeValue(accessTokenPayloadTestModel, newValue), - } - ), - ...conditional( - activeModelName === clientCredentialsPayloadTestModel.name && { - // Reset the field to undefined if the value is the same as the default value - tokenSample: updateSampleCodeValue(clientCredentialsPayloadTestModel, newValue), - } - ), - }; - - onChange(updatedValue); - }, - }; - }, - [activeModelName] - ); - - const validateSampleCode = useCallback( - (value: JwtCustomizerForm['testSample']) => { - for (const [_, sampleCode] of Object.entries(value)) { - if (sampleCode) { - try { - JSON.parse(sampleCode); - } catch { - return t('form_error.invalid_json'); - } - } - } - - return true; - }, - [t] - ); - - return ( -
- -
-
-
{t('tester.title')}
-
{t('tester.subtitle')}
-
-
-
- {formState.errors.testSample && ( -
{formState.errors.testSample.message}
- )} - ( - - )} - /> - {testResult && ( - { - setTestResult(undefined); - }} - /> - )} -
-
-
- ); -} - -export default TestTab; diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/TestTab/index.module.scss b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/TestTab/index.module.scss new file mode 100644 index 000000000..beb94a75e --- /dev/null +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/TestTab/index.module.scss @@ -0,0 +1,5 @@ +@use '@/scss/underscore' as _; + +.error { + color: var(--color-error); +} diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/TestTab/index.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/TestTab/index.tsx new file mode 100644 index 000000000..ca6e10d25 --- /dev/null +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/TestTab/index.tsx @@ -0,0 +1,129 @@ +import { LogtoJwtTokenPath } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; +import classNames from 'classnames'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Controller, useFormContext, type ControllerRenderProps } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import MonacoCodeEditor, { + type ModelControl, +} from '@/pages/CustomizeJwtDetails/MainContent/MonacoCodeEditor'; +import { type JwtCustomizerForm } from '@/pages/CustomizeJwtDetails/type'; +import { + accessTokenPayloadTestModel, + clientCredentialsPayloadTestModel, + userContextTestModel, +} from '@/pages/CustomizeJwtDetails/utils/config'; + +import * as tabContentStyles from '../index.module.scss'; + +import * as styles from './index.module.scss'; + +type Props = { + isActive: boolean; +}; + +const accessTokenModelSettings = [accessTokenPayloadTestModel, userContextTestModel]; +const clientCredentialsModelSettings = [clientCredentialsPayloadTestModel]; + +function TestTab({ isActive }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.jwt_claims' }); + const [activeModelName, setActiveModelName] = useState(); + + const { watch, control, formState } = useFormContext(); + const tokenType = watch('tokenType'); + + const editorModels = useMemo( + () => + tokenType === LogtoJwtTokenPath.AccessToken + ? accessTokenModelSettings + : clientCredentialsModelSettings, + [tokenType] + ); + + useEffect(() => { + setActiveModelName(editorModels[0]?.name); + }, [editorModels, tokenType]); + + const getModelControllerProps = useCallback( + ({ value, onChange }: ControllerRenderProps): ModelControl => { + return { + value: + activeModelName === userContextTestModel.name ? value.contextSample : value.tokenSample, + onChange: (newValue: string | undefined) => { + // Form value is a object we need to update the specific field + const updatedValue: JwtCustomizerForm['testSample'] = { + ...value, + ...conditional( + activeModelName === userContextTestModel.name && { + contextSample: newValue, + } + ), + ...conditional( + activeModelName === accessTokenPayloadTestModel.name && { + tokenSample: newValue, + } + ), + ...conditional( + activeModelName === clientCredentialsPayloadTestModel.name && { + // Reset the field to undefined if the value is the same as the default value + tokenSample: newValue, + } + ), + }; + + onChange(updatedValue); + }, + }; + }, + [activeModelName] + ); + + const validateSampleCode = useCallback( + (value: JwtCustomizerForm['testSample']) => { + for (const [_, sampleCode] of Object.entries(value)) { + if (sampleCode) { + try { + JSON.parse(sampleCode); + } catch { + return t('form_error.invalid_json'); + } + } + } + + return true; + }, + [t] + ); + + return ( +
+
{t('tester.subtitle')}
+
+ ( + + )} + /> + {formState.errors.testSample && ( +
{formState.errors.testSample.message}
+ )} +
+
+ ); +} + +export default TestTab; diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/index.module.scss b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/index.module.scss index 5ff83a94b..25f20ce16 100644 --- a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/index.module.scss +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/index.module.scss @@ -12,7 +12,7 @@ align-items: center; border-radius: 100px; color: var(--color-text); - background: transparent; + background: var(--color-layer-1); border: 1px solid var(--color-specific-selected-disabled); svg { @@ -51,146 +51,8 @@ } } -.card { - .headerRow { - display: flex; - flex-direction: row; - gap: _.unit(4); - align-items: center; - } - - .cardHeader { - flex: 1; - } - - .cardTitle { - font: var(--font-label-2); - color: var(--color-text); - margin-bottom: _.unit(1); - } - - .cardSubtitle { - font: var(--font-body-2); - color: var(--color-text-secondary); - } - - .cardContent { - // Collapsible content should be hidden by default, margin space can only be set at the child level - > *:first-child { - margin-top: _.unit(6); - } - - > *:not(:last-child) { - margin-bottom: _.unit(4); - } - } - - .expandButton { - width: 24px; - height: 24px; - transition: transform 0.3s ease; - color: var(--color-text-secondary); - } - - &.collapsible { - .headerRow { - cursor: pointer; - user-select: none; - } - - .cardContent { - max-height: 0; - overflow: hidden; - transition: max-height 0.3s ease; - } - - &.expanded { - .expandButton { - transform: rotate(180deg); - } - - .cardContent { - max-height: 1000; - } - } - } -} - -.sampleCode { - :global { - /* stylelint-disable-next-line selector-class-pattern */ - .monaco-editor { - border-radius: 8px; - - /* stylelint-disable-next-line selector-class-pattern */ - .overflow-guard { - border-radius: 8px; - } - - /* stylelint-disable-next-line selector-class-pattern */ - .lines-content { - padding: 0 16px; - } - } - } -} - -.envVariablesField { - margin-bottom: _.unit(4); -} - -/** Test Tab */ - -.shrinkCodeEditor { - height: 50%; -} - -.testResult { - margin-top: _.unit(3); - flex: 1; - background-color: var(--color-bg-layer-2); - border-radius: 8px; - border: 1px solid var(--color-divider); - font-family: 'Roboto Mono', monospace; - overflow: auto; - - .testResultHeader { - padding: _.unit(3) _.unit(4); - display: flex; - justify-content: space-between; - align-items: center; - font: var(--font-label-2); - font-family: 'Roboto Mono', monospace; - color: var(--color-text); - } - - .testResultContent { - padding: _.unit(2) _.unit(4); - font: var(--font-body-2); - overflow: auto; - - pre { - white-space: pre-wrap; - - &.error { - color: var(--color-error); - } - - &:first-child { - margin-top: 0; - } - } - } -} - -.error { - margin: _.unit(4) 0; - color: var(--color-error); -} - /** Flexbox */ - .flexColumn { display: flex; flex-direction: column; diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/index.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/index.tsx index 3a52d7d00..a2a41c05e 100644 --- a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/index.tsx +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/index.tsx @@ -2,7 +2,7 @@ import classNames from 'classnames'; import { useState } from 'react'; import BookIcon from '@/assets/icons/book.svg'; -import StartIcon from '@/assets/icons/start.svg'; +import FlaskIcon from '@/assets/icons/conical-flask.svg'; import Button from '@/ds-components/Button'; import InstructionTab from './InstructionTab'; @@ -24,7 +24,7 @@ function SettingsSection() {