mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
Merge pull request #5587 from logto-io/simeng-log-8525-ac-flow-iteration-22-refactor-details-page
refactor(console,phrases): refactor the customize jwt details page
This commit is contained in:
commit
39868d5fa2
74 changed files with 569 additions and 627 deletions
|
@ -18,7 +18,7 @@ import Role from '@/assets/icons/role.svg';
|
|||
import SecurityLock from '@/assets/icons/security-lock.svg';
|
||||
import EnterpriseSso from '@/assets/icons/single-sign-on.svg';
|
||||
import Web from '@/assets/icons/web.svg';
|
||||
import { isDevFeaturesEnabled, isCloud } from '@/consts/env';
|
||||
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
|
||||
|
||||
type SidebarItem = {
|
||||
Icon: FC;
|
||||
|
@ -128,7 +128,7 @@ export const useSidebarMenuItems = (): {
|
|||
},
|
||||
{
|
||||
Icon: JwtClaims,
|
||||
title: 'jwt_customizer',
|
||||
title: 'customize_jwt',
|
||||
isHidden: !isDevFeaturesEnabled,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -27,6 +27,7 @@ import AuditLogs from '@/pages/AuditLogs';
|
|||
import ConnectorDetails from '@/pages/ConnectorDetails';
|
||||
import Connectors from '@/pages/Connectors';
|
||||
import CustomizeJwt from '@/pages/CustomizeJwt';
|
||||
import CustomizeJwtDetails from '@/pages/CustomizeJwtDetails';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import EnterpriseSsoConnectors from '@/pages/EnterpriseSso';
|
||||
import EnterpriseSsoConnectorDetails from '@/pages/EnterpriseSsoDetails';
|
||||
|
@ -244,6 +245,7 @@ function ConsoleContent() {
|
|||
{isCloud && isDevFeaturesEnabled && (
|
||||
<Route path="jwt-customizer">
|
||||
<Route index element={<CustomizeJwt />} />
|
||||
<Route path=":tokenType/:action" element={<CustomizeJwtDetails />} />
|
||||
</Route>
|
||||
)}
|
||||
</Routes>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { HTTPError } from 'ky';
|
||||
import type ky from 'ky';
|
||||
import { HTTPError } from 'ky';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Fetcher } from 'swr';
|
||||
|
@ -22,6 +22,7 @@ const useSwrFetcher: UseSwrFetcherHook = <T>(api: KyInstance) => {
|
|||
async (resource: string) => {
|
||||
try {
|
||||
const response = await api.get(resource);
|
||||
|
||||
const data = await response.json<T>();
|
||||
|
||||
if (typeof resource === 'string' && resource.includes('?')) {
|
||||
|
@ -42,6 +43,7 @@ const useSwrFetcher: UseSwrFetcherHook = <T>(api: KyInstance) => {
|
|||
} catch (error: unknown) {
|
||||
if (error instanceof HTTPError) {
|
||||
const { response } = error;
|
||||
|
||||
// See https://stackoverflow.com/questions/53511974/javascript-fetch-failed-to-execute-json-on-response-body-stream-is-locked
|
||||
// for why `.clone()` is needed
|
||||
throw new RequestError(response.status, await response.clone().json());
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { type LogtoJwtTokenPath } from '@logto/schemas';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import PlusIcon from '@/assets/icons/plus.svg';
|
||||
import Button from '@/ds-components/Button';
|
||||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||
import { getPagePath } from '@/pages/CustomizeJwt/utils/path';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -13,7 +13,7 @@ type Props = {
|
|||
|
||||
function CreateButton({ tokenType }: Props) {
|
||||
const link = getPagePath(tokenType, 'create');
|
||||
const navigate = useNavigate();
|
||||
const { navigate } = useTenantPathname();
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { LogtoJwtTokenPath } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSWRConfig } from 'swr';
|
||||
|
||||
import DeletIcon from '@/assets/icons/delete.svg';
|
||||
|
@ -9,6 +8,7 @@ import EditIcon from '@/assets/icons/edit.svg';
|
|||
import Button from '@/ds-components/Button';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||
import { getApiPath, getPagePath } from '@/pages/CustomizeJwt/utils/path';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -21,7 +21,7 @@ function CustomizerItem({ tokenType }: Props) {
|
|||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const apiLink = getApiPath(tokenType);
|
||||
const editLink = getPagePath(tokenType, 'edit');
|
||||
const navigate = useNavigate();
|
||||
const { navigate } = useTenantPathname();
|
||||
const { show } = useConfirmModal();
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
|
@ -36,7 +36,7 @@ function CustomizerItem({ tokenType }: Props) {
|
|||
|
||||
if (confirm) {
|
||||
await api.delete(apiLink);
|
||||
await mutate(apiLink);
|
||||
await mutate(apiLink, undefined);
|
||||
}
|
||||
}, [api, apiLink, mutate, show, t]);
|
||||
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { type LogtoJwtTokenPath } from '@logto/schemas';
|
||||
|
||||
import { type Action } from './type';
|
||||
|
||||
export const getApiPath = (tokenType: LogtoJwtTokenPath) =>
|
||||
`api/configs/jwt-customizer/${tokenType}`;
|
||||
|
||||
export const getPagePath = (tokenType?: LogtoJwtTokenPath, action?: 'create' | 'edit') => {
|
||||
export const getPagePath = (tokenType?: LogtoJwtTokenPath, action?: Action) => {
|
||||
if (!tokenType) {
|
||||
return '/customize-jwt';
|
||||
}
|
||||
|
|
1
packages/console/src/pages/CustomizeJwt/utils/type.ts
Normal file
1
packages/console/src/pages/CustomizeJwt/utils/type.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type Action = 'create' | 'edit';
|
|
@ -0,0 +1,17 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
|
||||
.codePanel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.cardTitle {
|
||||
font: var(--font-label-2);
|
||||
margin-bottom: _.unit(3);
|
||||
}
|
||||
|
||||
.flexGrow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
/* Code Editor for the custom JWT claims script. */
|
||||
import { LogtoJwtTokenPath } from '@logto/schemas';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { Controller, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { CodeEditorLoadingContext } from '@/pages/CustomizeJwtDetails/CodeEditorLoadingContext';
|
||||
import MonacoCodeEditor, { type ModelSettings } from '@/pages/CustomizeJwtDetails/MonacoCodeEditor';
|
||||
import { type JwtCustomizerForm } from '@/pages/CustomizeJwtDetails/type';
|
||||
import {
|
||||
accessTokenJwtCustomizerModel,
|
||||
clientCredentialsModel,
|
||||
} from '@/pages/CustomizeJwtDetails/utils/config';
|
||||
import { buildEnvironmentVariablesTypeDefinition } from '@/pages/CustomizeJwtDetails/utils/type-definitions';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
function ScriptSection() {
|
||||
const { watch, control } = useFormContext<JwtCustomizerForm>();
|
||||
const tokenType = watch('tokenType');
|
||||
|
||||
// Need to use useWatch hook to subscribe the mutation of the environmentVariables field
|
||||
// Otherwise, the default watch function's return value won't mutate when the environmentVariables field changes
|
||||
const envVariables = useWatch({
|
||||
control,
|
||||
name: 'environmentVariables',
|
||||
});
|
||||
|
||||
const environmentVariablesTypeDefinition = useMemo(
|
||||
() => buildEnvironmentVariablesTypeDefinition(envVariables),
|
||||
[envVariables]
|
||||
);
|
||||
|
||||
// Get the active model based on the token type
|
||||
const activeModel = useMemo<ModelSettings>(
|
||||
() =>
|
||||
tokenType === LogtoJwtTokenPath.AccessToken
|
||||
? accessTokenJwtCustomizerModel
|
||||
: clientCredentialsModel,
|
||||
[tokenType]
|
||||
);
|
||||
|
||||
// Set the Monaco editor loaded state to true when the editor is mounted
|
||||
const { setIsMonacoLoaded } = useContext(CodeEditorLoadingContext);
|
||||
|
||||
const onMountHandler = useCallback(() => {
|
||||
setIsMonacoLoaded(true);
|
||||
}, [setIsMonacoLoaded]);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
// Force rerender the controller when the token type changes
|
||||
// Otherwise the input field will not be updated
|
||||
key={tokenType}
|
||||
control={control}
|
||||
name="script"
|
||||
render={({ field: { onChange, value }, formState: { defaultValues } }) => (
|
||||
<MonacoCodeEditor
|
||||
className={styles.flexGrow}
|
||||
enabledActions={['restore', 'copy']}
|
||||
models={[activeModel]}
|
||||
activeModelName={activeModel.name}
|
||||
value={value}
|
||||
environmentVariablesDefinition={environmentVariablesTypeDefinition}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScriptSection;
|
|
@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import FormField from '@/ds-components/FormField';
|
||||
import KeyValueInputField from '@/ds-components/KeyValueInputField';
|
||||
|
||||
import { type JwtClaimsFormType } from '../type';
|
||||
import { type JwtCustomizerForm } from '../../type';
|
||||
|
||||
const isValidKey = (key: string) => {
|
||||
return /^\w+$/.test(key);
|
||||
|
@ -26,10 +26,10 @@ function EnvironmentVariablesField({ className }: Props) {
|
|||
errors: { environmentVariables: envVariableErrors },
|
||||
submitCount,
|
||||
},
|
||||
} = useFormContext<JwtClaimsFormType>();
|
||||
} = useFormContext<JwtCustomizerForm>();
|
||||
|
||||
// Read the form controller from the context @see {@link https://react-hook-form.com/docs/usefieldarray}
|
||||
const { fields, remove, append } = useFieldArray<JwtClaimsFormType>({
|
||||
const { fields, remove, append } = useFieldArray<JwtCustomizerForm>({
|
||||
name: 'environmentVariables',
|
||||
});
|
||||
|
|
@ -4,18 +4,18 @@ import classNames from 'classnames';
|
|||
import { useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { type JwtClaimsFormType } from '../type';
|
||||
import { type JwtCustomizerForm } from '../../type';
|
||||
import {
|
||||
environmentVariablesCodeExample,
|
||||
fetchExternalDataCodeExample,
|
||||
sampleCodeEditorOptions,
|
||||
typeDefinitionCodeEditorOptions,
|
||||
fetchExternalDataCodeExample,
|
||||
environmentVariablesCodeExample,
|
||||
} from '../utils/config';
|
||||
} from '../../utils/config';
|
||||
import {
|
||||
accessTokenPayloadTypeDefinition,
|
||||
clientCredentialsPayloadTypeDefinition,
|
||||
jwtCustomizerUserContextTypeDefinition,
|
||||
} from '../utils/type-definitions';
|
||||
} from '../../utils/type-definitions';
|
||||
|
||||
import EnvironmentVariablesField from './EnvironmentVariablesField';
|
||||
import GuideCard, { CardType } from './GuideCard';
|
||||
|
@ -29,7 +29,7 @@ type Props = {
|
|||
function InstructionTab({ isActive }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const { watch } = useFormContext<JwtClaimsFormType>();
|
||||
const { watch } = useFormContext<JwtCustomizerForm>();
|
||||
const tokenType = watch('tokenType');
|
||||
|
||||
return (
|
|
@ -11,14 +11,14 @@ 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 JwtClaimsFormType } from '../type';
|
||||
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';
|
||||
} from '../../utils/config';
|
||||
import { formatFormDataToTestRequestPayload } from '../../utils/format';
|
||||
|
||||
import TestResult, { type TestResultData } from './TestResult';
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -45,7 +45,7 @@ function TestTab({ isActive }: Props) {
|
|||
const [activeModelName, setActiveModelName] = useState<string>();
|
||||
const api = useApi({ hideErrorToast: true });
|
||||
|
||||
const { watch, control, formState, getValues } = useFormContext<JwtClaimsFormType>();
|
||||
const { watch, control, formState, getValues } = useFormContext<JwtCustomizerForm>();
|
||||
const tokenType = watch('tokenType');
|
||||
|
||||
const editorModels = useMemo(
|
||||
|
@ -99,13 +99,13 @@ function TestTab({ isActive }: Props) {
|
|||
}, [api, getValues]);
|
||||
|
||||
const getModelControllerProps = useCallback(
|
||||
({ value, onChange }: ControllerRenderProps<JwtClaimsFormType, 'testSample'>): ModelControl => {
|
||||
({ value, onChange }: ControllerRenderProps<JwtCustomizerForm, 'testSample'>): ModelControl => {
|
||||
return {
|
||||
value:
|
||||
activeModelName === userContextTestModel.name ? value?.contextSample : value?.tokenSample,
|
||||
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: JwtClaimsFormType['testSample'] = {
|
||||
const updatedValue: JwtCustomizerForm['testSample'] = {
|
||||
...value,
|
||||
...conditional(
|
||||
activeModelName === userContextTestModel.name && {
|
||||
|
@ -133,11 +133,7 @@ function TestTab({ isActive }: Props) {
|
|||
);
|
||||
|
||||
const validateSampleCode = useCallback(
|
||||
(value: JwtClaimsFormType['testSample']) => {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
(value: JwtCustomizerForm['testSample']) => {
|
||||
for (const [_, sampleCode] of Object.entries(value)) {
|
||||
if (sampleCode) {
|
||||
try {
|
|
@ -0,0 +1,17 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
margin-bottom: _.unit(6);
|
||||
|
||||
&:first-child {
|
||||
margin-right: _.unit(3);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
import { type LogtoJwtTokenPath } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { type KeyedMutator } from 'swr';
|
||||
|
||||
import SubmitFormChangesActionBar from '@/components/SubmitFormChangesActionBar';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
|
||||
import { type Action, type JwtCustomizer, type JwtCustomizerForm } from '../type';
|
||||
import { formatFormDataToRequestData, formatResponseDataToFormData } from '../utils/format';
|
||||
import { getApiPath } from '../utils/path';
|
||||
|
||||
import ScriptSection from './ScriptSection';
|
||||
import SettingsSection from './SettingsSection';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props<T extends LogtoJwtTokenPath> = {
|
||||
className?: string;
|
||||
token: T;
|
||||
data?: JwtCustomizer<T>;
|
||||
mutate: KeyedMutator<JwtCustomizer<T>>;
|
||||
action: Action;
|
||||
};
|
||||
|
||||
function MainContent<T extends LogtoJwtTokenPath>({
|
||||
className,
|
||||
token,
|
||||
data,
|
||||
mutate,
|
||||
action,
|
||||
}: Props<T>) {
|
||||
const api = useApi();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const methods = useForm<JwtCustomizerForm>({
|
||||
defaultValues: formatResponseDataToFormData(token, data),
|
||||
});
|
||||
|
||||
const {
|
||||
formState: { isDirty, isSubmitting },
|
||||
reset,
|
||||
handleSubmit,
|
||||
} = methods;
|
||||
|
||||
const onSubmitHandler = handleSubmit(
|
||||
trySubmitSafe(async (data) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { tokenType } = data;
|
||||
const payload = formatFormDataToRequestData(data);
|
||||
|
||||
await api.put(getApiPath(tokenType), { json: payload });
|
||||
|
||||
if (action === 'create') {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await mutate();
|
||||
|
||||
reset(formatResponseDataToFormData(tokenType, result));
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormProvider {...methods}>
|
||||
<form className={classNames(styles.content, className)}>
|
||||
<ScriptSection />
|
||||
<SettingsSection />
|
||||
</form>
|
||||
</FormProvider>
|
||||
<SubmitFormChangesActionBar
|
||||
// Always show the action bar if is the create mode
|
||||
isOpen={isDirty || action === 'create'}
|
||||
isSubmitting={isSubmitting}
|
||||
onDiscard={
|
||||
// If the form is in create mode, navigate back to the previous page
|
||||
action === 'create'
|
||||
? () => {
|
||||
navigate(-1);
|
||||
}
|
||||
: reset
|
||||
}
|
||||
onSubmit={onSubmitHandler}
|
||||
/>
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty && !isSubmitting} onConfirm={reset} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MainContent;
|
|
@ -17,7 +17,6 @@ export const defaultOptions: EditorProps['options'] = {
|
|||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
wordWrap: 'on',
|
||||
renderLineHighlight: 'none',
|
||||
fontFamily: 'Roboto Mono, monospace',
|
||||
fontSize: 14,
|
|
@ -1,4 +1,4 @@
|
|||
import { Editor, type BeforeMount, type OnMount, useMonaco } from '@monaco-editor/react';
|
||||
import { Editor, useMonaco, type BeforeMount, type OnMount } from '@monaco-editor/react';
|
||||
import { type Nullable } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
@ -6,16 +6,15 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|||
import CopyToClipboard from '@/ds-components/CopyToClipboard';
|
||||
import { onKeyDownHandler } from '@/utils/a11y';
|
||||
|
||||
import CodeClearButton from './ActionButton/CodeClearButton.js';
|
||||
import CodeRestoreButton from './ActionButton/CodeRestoreButton.js';
|
||||
import { logtoDarkTheme, defaultOptions } from './config.js';
|
||||
import { defaultOptions, logtoDarkTheme } 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 { ModelSettings, ModelControl } from './type.js';
|
||||
export type { ModelControl, ModelSettings } from './type.js';
|
||||
|
||||
type ActionButtonType = 'clear' | 'restore' | 'copy';
|
||||
type ActionButtonType = 'restore' | 'copy';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
|
@ -136,15 +135,6 @@ function MonacoCodeEditor({
|
|||
))}
|
||||
</div>
|
||||
<div className={styles.actionButtons}>
|
||||
{enabledActions.includes('clear') && (
|
||||
<CodeClearButton
|
||||
onClick={() => {
|
||||
if (activeModel) {
|
||||
onChange?.(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{enabledActions.includes('restore') && (
|
||||
<CodeRestoreButton
|
||||
onClick={() => {
|
|
@ -1,5 +1,20 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
margin-bottom: _.unit(6);
|
||||
|
||||
&:first-child {
|
||||
margin-right: _.unit(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.blockShimmer {
|
||||
@include _.shimmering-animation;
|
||||
border-radius: 8px;
|
|
@ -3,8 +3,6 @@ import classNames from 'classnames';
|
|||
|
||||
import Card from '@/ds-components/Card';
|
||||
|
||||
import * as pageLayoutStyles from '../index.module.scss';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
|
@ -13,11 +11,8 @@ type Props = {
|
|||
|
||||
function PageLoadingSkeleton({ tokenType }: Props) {
|
||||
return (
|
||||
<div className={pageLayoutStyles.tabContent}>
|
||||
<Card className={pageLayoutStyles.codePanel}>
|
||||
<div className={classNames(styles.textShimmer, styles.title)} />
|
||||
<div className={styles.blockShimmer} />
|
||||
</Card>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.blockShimmer} />
|
||||
<div>
|
||||
<div className={classNames(styles.textShimmer, styles.large)} />
|
||||
<Card className={styles.card}>
|
|
@ -0,0 +1,16 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
66
packages/console/src/pages/CustomizeJwtDetails/index.tsx
Normal file
66
packages/console/src/pages/CustomizeJwtDetails/index.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { withAppInsights } from '@logto/app-insights/react/AppInsightsReact';
|
||||
import { type LogtoJwtTokenPath } from '@logto/schemas';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import DetailsPage from '@/components/DetailsPage';
|
||||
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
|
||||
|
||||
import { CodeEditorLoadingContext } from './CodeEditorLoadingContext';
|
||||
import MainContent from './MainContent';
|
||||
import PageLoadingSkeleton from './PageLoadingSkeleton';
|
||||
import * as styles from './index.module.scss';
|
||||
import { pageParamsGuard, type Action } from './type';
|
||||
import useDataFetch from './use-data-fetch';
|
||||
|
||||
type Props = {
|
||||
tokenType: LogtoJwtTokenPath;
|
||||
action: Action;
|
||||
};
|
||||
|
||||
function CustomizeJwtDetails({ tokenType, action }: Props) {
|
||||
const { isLoading, error, ...rest } = useDataFetch(tokenType, action);
|
||||
|
||||
const [isMonacoLoaded, setIsMonacoLoaded] = useState(false);
|
||||
|
||||
const codeEditorContextValue = useMemo(
|
||||
() => ({ isMonacoLoaded, setIsMonacoLoaded }),
|
||||
[isMonacoLoaded]
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailsPage
|
||||
backLink="/customize-jwt"
|
||||
backLinkTitle="jwt_claims.title"
|
||||
className={styles.container}
|
||||
>
|
||||
{(isLoading || !isMonacoLoaded) && <PageLoadingSkeleton tokenType={tokenType} />}
|
||||
|
||||
{!isLoading && (
|
||||
<CodeEditorLoadingContext.Provider value={codeEditorContextValue}>
|
||||
<MainContent
|
||||
action={action}
|
||||
token={tokenType}
|
||||
{...rest}
|
||||
className={isMonacoLoaded ? undefined : styles.hidden}
|
||||
/>
|
||||
</CodeEditorLoadingContext.Provider>
|
||||
)}
|
||||
</DetailsPage>
|
||||
);
|
||||
}
|
||||
|
||||
// Guard the parameters to ensure they are valid
|
||||
function CustomizeJwtDetailsWrapper() {
|
||||
const { tokenType, action } = useParams();
|
||||
|
||||
const params = pageParamsGuard.safeParse({ tokenType, action });
|
||||
|
||||
if (!params.success) {
|
||||
return <EmptyDataPlaceholder />;
|
||||
}
|
||||
|
||||
return <CustomizeJwtDetails tokenType={params.data.tokenType} action={params.data.action} />;
|
||||
}
|
||||
|
||||
export default withAppInsights(CustomizeJwtDetailsWrapper);
|
24
packages/console/src/pages/CustomizeJwtDetails/type.ts
Normal file
24
packages/console/src/pages/CustomizeJwtDetails/type.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import type { AccessTokenJwtCustomizer, ClientCredentialsJwtCustomizer } from '@logto/schemas';
|
||||
import { LogtoJwtTokenPath } from '@logto/schemas';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type JwtCustomizerForm = {
|
||||
tokenType: LogtoJwtTokenPath;
|
||||
script: string;
|
||||
environmentVariables?: Array<{ key: string; value: string }>;
|
||||
testSample: {
|
||||
contextSample?: string;
|
||||
tokenSample?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Action = 'create' | 'edit';
|
||||
|
||||
export type JwtCustomizer<T extends LogtoJwtTokenPath> = T extends LogtoJwtTokenPath.AccessToken
|
||||
? AccessTokenJwtCustomizer
|
||||
: ClientCredentialsJwtCustomizer;
|
||||
|
||||
export const pageParamsGuard = z.object({
|
||||
tokenType: z.nativeEnum(LogtoJwtTokenPath),
|
||||
action: z.union([z.literal('create'), z.literal('edit')]),
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
import { type LogtoJwtTokenPath } from '@logto/schemas';
|
||||
import { type ResponseError } from '@withtyped/client';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useSwrFetcher from '@/hooks/use-swr-fetcher';
|
||||
import { shouldRetryOnError } from '@/utils/request';
|
||||
|
||||
import { type Action, type JwtCustomizer } from './type';
|
||||
import { getApiPath } from './utils/path';
|
||||
|
||||
const useDataFetch = <T extends LogtoJwtTokenPath>(tokenType: T, action: Action) => {
|
||||
const apiPath = getApiPath(tokenType);
|
||||
const fetchApi = useApi({ hideErrorToast: true });
|
||||
const fetcher = useSwrFetcher<JwtCustomizer<T>>(fetchApi);
|
||||
|
||||
// Return undefined if action is create
|
||||
const { isLoading, data, mutate, error } = useSWR<JwtCustomizer<T>, ResponseError>(
|
||||
action === 'create' ? undefined : apiPath,
|
||||
{
|
||||
fetcher,
|
||||
shouldRetryOnError: shouldRetryOnError({ ignore: [404] }),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
// Show global loading status only if any of the fetchers are loading and no errors are present
|
||||
isLoading: isLoading && !error,
|
||||
data,
|
||||
mutate,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDataFetch;
|
|
@ -71,7 +71,7 @@ declare global {
|
|||
export { exports as default };
|
||||
`;
|
||||
|
||||
const defaultAccessTokenJwtCustomizerCode = `/**
|
||||
export const defaultAccessTokenJwtCustomizerCode = `/**
|
||||
* This function is called to get custom claims for the JWT token.
|
||||
*
|
||||
* @param {${JwtCustomizerTypeDefinitionKey.AccessTokenPayload}} token -The JWT token.
|
||||
|
@ -86,7 +86,7 @@ exports.getCustomJwtClaims = async (token, data) => {
|
|||
return {};
|
||||
}`;
|
||||
|
||||
const defaultClientCredentialsJwtCustomizerCode = `/**
|
||||
export const defaultClientCredentialsJwtCustomizerCode = `/**
|
||||
* This function is called to get custom claims for the JWT token.
|
||||
*
|
||||
* @param {${JwtCustomizerTypeDefinitionKey.ClientCredentialsPayload}} token -The JWT token.
|
||||
|
@ -101,7 +101,7 @@ exports.getCustomJwtClaims = async (token) => {
|
|||
|
||||
export const accessTokenJwtCustomizerModel: ModelSettings = {
|
||||
name: 'user-jwt.ts',
|
||||
title: 'TypeScript',
|
||||
title: 'User access token',
|
||||
language: 'typescript',
|
||||
defaultValue: defaultAccessTokenJwtCustomizerCode,
|
||||
extraLibs: [
|
||||
|
@ -118,7 +118,7 @@ export const accessTokenJwtCustomizerModel: ModelSettings = {
|
|||
|
||||
export const clientCredentialsModel: ModelSettings = {
|
||||
name: 'machine-to-machine-jwt.ts',
|
||||
title: 'TypeScript',
|
||||
title: 'Machine-to-machine token',
|
||||
language: 'typescript',
|
||||
defaultValue: defaultClientCredentialsJwtCustomizerCode,
|
||||
extraLibs: [
|
120
packages/console/src/pages/CustomizeJwtDetails/utils/format.ts
Normal file
120
packages/console/src/pages/CustomizeJwtDetails/utils/format.ts
Normal file
|
@ -0,0 +1,120 @@
|
|||
import { LogtoJwtTokenPath, type AccessTokenJwtCustomizer } from '@logto/schemas';
|
||||
|
||||
import type { JwtCustomizer, JwtCustomizerForm } from '../type';
|
||||
|
||||
import {
|
||||
defaultAccessTokenJwtCustomizerCode,
|
||||
defaultAccessTokenPayload,
|
||||
defaultClientCredentialsJwtCustomizerCode,
|
||||
defaultClientCredentialsPayload,
|
||||
defaultUserTokenContextData,
|
||||
} from './config';
|
||||
|
||||
const formatEnvVariablesResponseToFormData = (
|
||||
enVariables?: AccessTokenJwtCustomizer['envVars']
|
||||
) => {
|
||||
if (!enVariables) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Object.entries(enVariables).map(([key, value]) => ({ key, value }));
|
||||
};
|
||||
|
||||
const formatEnvVariablesFormDataToRequest = (
|
||||
envVariables: JwtCustomizerForm['environmentVariables']
|
||||
) => {
|
||||
if (!envVariables) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = envVariables.filter(({ key, value }) => key && value);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Object.fromEntries(entries.map(({ key, value }) => [key, value]));
|
||||
};
|
||||
|
||||
const formatSampleCodeJsonToString = (sampleJson?: AccessTokenJwtCustomizer['contextSample']) => {
|
||||
if (!sampleJson) {
|
||||
return;
|
||||
}
|
||||
|
||||
return JSON.stringify(sampleJson, null, 2);
|
||||
};
|
||||
|
||||
const formatSampleCodeStringToJson = (sampleCode?: string) => {
|
||||
if (!sampleCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax -- guarded by back-end validation
|
||||
return JSON.parse(sampleCode) as Record<string, unknown>;
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const defaultValues = Object.freeze({
|
||||
[LogtoJwtTokenPath.AccessToken]: {
|
||||
script: defaultAccessTokenJwtCustomizerCode,
|
||||
tokenSample: defaultAccessTokenPayload,
|
||||
contextSample: defaultUserTokenContextData,
|
||||
},
|
||||
[LogtoJwtTokenPath.ClientCredentials]: {
|
||||
script: defaultClientCredentialsJwtCustomizerCode,
|
||||
tokenSample: defaultClientCredentialsPayload,
|
||||
contextSample: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
export const formatResponseDataToFormData = <T extends LogtoJwtTokenPath>(
|
||||
tokenType: T,
|
||||
data?: JwtCustomizer<T>
|
||||
): JwtCustomizerForm => {
|
||||
return {
|
||||
tokenType,
|
||||
script: data?.script ?? defaultValues[tokenType].script,
|
||||
environmentVariables: formatEnvVariablesResponseToFormData(data?.envVars) ?? [
|
||||
{ key: '', value: '' },
|
||||
],
|
||||
testSample: {
|
||||
tokenSample: formatSampleCodeJsonToString(
|
||||
data?.tokenSample ?? defaultValues[tokenType].tokenSample
|
||||
),
|
||||
contextSample: formatSampleCodeJsonToString(
|
||||
data?.contextSample ?? defaultValues[tokenType].contextSample
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const formatFormDataToRequestData = (data: JwtCustomizerForm) => {
|
||||
return {
|
||||
script: data.script,
|
||||
envVars: formatEnvVariablesFormDataToRequest(data.environmentVariables),
|
||||
tokenSample: formatSampleCodeStringToJson(data.testSample.tokenSample),
|
||||
contextSample: formatSampleCodeStringToJson(data.testSample.contextSample),
|
||||
};
|
||||
};
|
||||
|
||||
export const formatFormDataToTestRequestPayload = ({
|
||||
tokenType,
|
||||
script,
|
||||
environmentVariables,
|
||||
testSample,
|
||||
}: JwtCustomizerForm) => {
|
||||
return {
|
||||
tokenType,
|
||||
payload: {
|
||||
script,
|
||||
envVars: formatEnvVariablesFormDataToRequest(environmentVariables),
|
||||
tokenSample:
|
||||
formatSampleCodeStringToJson(testSample.tokenSample) ??
|
||||
defaultValues[tokenType].tokenSample,
|
||||
contextSample:
|
||||
formatSampleCodeStringToJson(testSample.contextSample) ??
|
||||
defaultValues[tokenType].contextSample,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
import { type LogtoJwtTokenPath } from '@logto/schemas';
|
||||
|
||||
export const getApiPath = (tokenType: LogtoJwtTokenPath) =>
|
||||
`api/configs/jwt-customizer/${tokenType}`;
|
|
@ -1,11 +1,11 @@
|
|||
import {
|
||||
JwtCustomizerTypeDefinitionKey,
|
||||
accessTokenPayloadTypeDefinition,
|
||||
jwtCustomizerUserContextTypeDefinition,
|
||||
clientCredentialsPayloadTypeDefinition,
|
||||
jwtCustomizerUserContextTypeDefinition,
|
||||
} from '@/consts/jwt-customizer-type-definition';
|
||||
|
||||
import { type JwtClaimsFormType } from '../type';
|
||||
import { type JwtCustomizerForm } from '../type';
|
||||
|
||||
export {
|
||||
JwtCustomizerTypeDefinitionKey,
|
||||
|
@ -24,7 +24,7 @@ export const buildClientCredentialsJwtCustomizerContextTsDefinition = () =>
|
|||
`declare ${clientCredentialsPayloadTypeDefinition}`;
|
||||
|
||||
export const buildEnvironmentVariablesTypeDefinition = (
|
||||
envVariables?: JwtClaimsFormType['environmentVariables']
|
||||
envVariables?: JwtCustomizerForm['environmentVariables']
|
||||
) => {
|
||||
const typeDefinition = envVariables
|
||||
? `{
|
|
@ -1,112 +0,0 @@
|
|||
import {
|
||||
type AccessTokenJwtCustomizer,
|
||||
LogtoJwtTokenPath,
|
||||
type ClientCredentialsJwtCustomizer,
|
||||
} from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useMemo } from 'react';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { type KeyedMutator } from 'swr';
|
||||
|
||||
import SubmitFormChangesActionBar from '@/components/SubmitFormChangesActionBar';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
|
||||
import ScriptSection from './ScriptSection';
|
||||
import SettingsSection from './SettingsSection';
|
||||
import * as styles from './index.module.scss';
|
||||
import { type JwtClaimsFormType } from './type';
|
||||
import {
|
||||
formatResponseDataToFormData,
|
||||
formatFormDataToRequestData,
|
||||
getApiPath,
|
||||
} from './utils/format';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
tab: LogtoJwtTokenPath;
|
||||
accessTokenJwtCustomizer: AccessTokenJwtCustomizer | undefined;
|
||||
clientCredentialsJwtCustomizer: ClientCredentialsJwtCustomizer | undefined;
|
||||
mutateAccessTokenJwtCustomizer: KeyedMutator<AccessTokenJwtCustomizer>;
|
||||
mutateClientCredentialsJwtCustomizer: KeyedMutator<ClientCredentialsJwtCustomizer>;
|
||||
};
|
||||
|
||||
function Main({
|
||||
className,
|
||||
tab,
|
||||
accessTokenJwtCustomizer,
|
||||
clientCredentialsJwtCustomizer,
|
||||
mutateAccessTokenJwtCustomizer,
|
||||
mutateClientCredentialsJwtCustomizer,
|
||||
}: Props) {
|
||||
const api = useApi();
|
||||
|
||||
const userJwtClaimsForm = useForm<JwtClaimsFormType>({
|
||||
defaultValues: formatResponseDataToFormData(
|
||||
LogtoJwtTokenPath.AccessToken,
|
||||
accessTokenJwtCustomizer
|
||||
),
|
||||
});
|
||||
|
||||
const machineToMachineJwtClaimsForm = useForm<JwtClaimsFormType>({
|
||||
defaultValues: formatResponseDataToFormData(
|
||||
LogtoJwtTokenPath.ClientCredentials,
|
||||
clientCredentialsJwtCustomizer
|
||||
),
|
||||
});
|
||||
|
||||
const activeForm = useMemo(
|
||||
() =>
|
||||
tab === LogtoJwtTokenPath.AccessToken ? userJwtClaimsForm : machineToMachineJwtClaimsForm,
|
||||
[machineToMachineJwtClaimsForm, tab, userJwtClaimsForm]
|
||||
);
|
||||
|
||||
const {
|
||||
formState: { isDirty, isSubmitting },
|
||||
reset,
|
||||
handleSubmit,
|
||||
} = activeForm;
|
||||
|
||||
const onSubmitHandler = handleSubmit(
|
||||
trySubmitSafe(async (data) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { tokenType } = data;
|
||||
const payload = formatFormDataToRequestData(data);
|
||||
|
||||
await api.put(getApiPath(tokenType), { json: payload });
|
||||
|
||||
const mutate =
|
||||
tokenType === LogtoJwtTokenPath.AccessToken
|
||||
? mutateAccessTokenJwtCustomizer
|
||||
: mutateClientCredentialsJwtCustomizer;
|
||||
|
||||
const result = await mutate();
|
||||
|
||||
reset(formatResponseDataToFormData(tokenType, result));
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormProvider {...activeForm}>
|
||||
<form className={classNames(styles.tabContent, className)}>
|
||||
<ScriptSection />
|
||||
<SettingsSection />
|
||||
</form>
|
||||
</FormProvider>
|
||||
<SubmitFormChangesActionBar
|
||||
isOpen={isDirty}
|
||||
isSubmitting={isSubmitting}
|
||||
onDiscard={reset}
|
||||
onSubmit={onSubmitHandler}
|
||||
/>
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty && !isSubmitting} onConfirm={reset} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Main;
|
|
@ -1,24 +0,0 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ClearIcon from '@/assets/icons/clear.svg';
|
||||
|
||||
import ActionButton from './index';
|
||||
|
||||
type Props = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function CodeClearButton({ onClick }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
actionTip={t('jwt_claims.clear')}
|
||||
actionSuccessTip={t('jwt_claims.cleared')}
|
||||
icon={<ClearIcon />}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodeClearButton;
|
|
@ -1,93 +0,0 @@
|
|||
/* Code Editor for the custom JWT claims script. */
|
||||
import { LogtoJwtTokenPath } from '@logto/schemas';
|
||||
import { useMemo, useContext, useCallback } from 'react';
|
||||
import { useFormContext, Controller, useWatch } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Card from '@/ds-components/Card';
|
||||
|
||||
import { CodeEditorLoadingContext } from './CodeEditorLoadingContext';
|
||||
import MonacoCodeEditor, { type ModelSettings } from './MonacoCodeEditor';
|
||||
import * as styles from './index.module.scss';
|
||||
import { type JwtClaimsFormType } from './type';
|
||||
import { accessTokenJwtCustomizerModel, clientCredentialsModel } from './utils/config';
|
||||
import { buildEnvironmentVariablesTypeDefinition } from './utils/type-definitions';
|
||||
|
||||
const titlePhrases = Object.freeze({
|
||||
[LogtoJwtTokenPath.AccessToken]: 'user_jwt',
|
||||
[LogtoJwtTokenPath.ClientCredentials]: 'machine_to_machine_jwt',
|
||||
});
|
||||
|
||||
function ScriptSection() {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const { watch, control } = useFormContext<JwtClaimsFormType>();
|
||||
|
||||
const tokenType = watch('tokenType');
|
||||
|
||||
// Need to use useWatch hook to subscribe the mutation of the environmentVariables field
|
||||
// Otherwise, the default watch function's return value won't mutate when the environmentVariables field changes
|
||||
const envVariables = useWatch({
|
||||
control,
|
||||
name: 'environmentVariables',
|
||||
});
|
||||
|
||||
const environmentVariablesTypeDefinition = useMemo(
|
||||
() => buildEnvironmentVariablesTypeDefinition(envVariables),
|
||||
[envVariables]
|
||||
);
|
||||
|
||||
const { setIsMonacoLoaded } = useContext(CodeEditorLoadingContext);
|
||||
|
||||
const activeModel = useMemo<ModelSettings>(
|
||||
() =>
|
||||
tokenType === LogtoJwtTokenPath.AccessToken
|
||||
? accessTokenJwtCustomizerModel
|
||||
: clientCredentialsModel,
|
||||
[tokenType]
|
||||
);
|
||||
|
||||
const onMountHandler = useCallback(() => {
|
||||
setIsMonacoLoaded(true);
|
||||
}, [setIsMonacoLoaded]);
|
||||
|
||||
return (
|
||||
<Card className={styles.codePanel}>
|
||||
<div className={styles.cardTitle}>
|
||||
{t('jwt_claims.code_editor_title', {
|
||||
token: t(`jwt_claims.${titlePhrases[tokenType]}`),
|
||||
})}
|
||||
</div>
|
||||
<Controller
|
||||
// Force rerender the controller when the token type changes
|
||||
// Otherwise the input field will not be updated
|
||||
key={tokenType}
|
||||
control={control}
|
||||
name="script"
|
||||
render={({ field: { onChange, value }, formState: { defaultValues } }) => (
|
||||
<MonacoCodeEditor
|
||||
className={styles.flexGrow}
|
||||
enabledActions={['clear', 'copy']}
|
||||
models={[activeModel]}
|
||||
activeModelName={activeModel.name}
|
||||
value={value}
|
||||
environmentVariablesDefinition={environmentVariablesTypeDefinition}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScriptSection;
|
|
@ -1,50 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
|
||||
.tabNav {
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
margin-bottom: _.unit(6);
|
||||
|
||||
&:first-child {
|
||||
margin-right: _.unit(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.codePanel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.cardTitle {
|
||||
font: var(--font-label-2);
|
||||
margin-bottom: _.unit(3);
|
||||
}
|
||||
|
||||
.flexGrow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
import { withAppInsights } from '@logto/app-insights/react/AppInsightsReact';
|
||||
import { LogtoJwtTokenPath } from '@logto/schemas';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CardTitle from '@/ds-components/CardTitle';
|
||||
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
|
||||
|
||||
import { CodeEditorLoadingContext } from './CodeEditorLoadingContext';
|
||||
import Main from './Main';
|
||||
import PageLoadingSkeleton from './PageLoadingSkeleton';
|
||||
import * as styles from './index.module.scss';
|
||||
import useJwtCustomizer from './use-jwt-customizer';
|
||||
|
||||
const tabPhrases = Object.freeze({
|
||||
[LogtoJwtTokenPath.AccessToken]: 'user_jwt.card_field',
|
||||
[LogtoJwtTokenPath.ClientCredentials]: 'machine_to_machine_jwt.card_field',
|
||||
});
|
||||
|
||||
const getPagePath = (tokenType: LogtoJwtTokenPath) => `/jwt-customizer/${tokenType}`;
|
||||
|
||||
type Props = {
|
||||
tab: LogtoJwtTokenPath;
|
||||
};
|
||||
|
||||
function JwtClaims({ tab }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const { isLoading, ...rest } = useJwtCustomizer();
|
||||
const [isMonacoLoaded, setIsMonacoLoaded] = useState(false);
|
||||
|
||||
const codeEditorContextValue = useMemo(
|
||||
() => ({ isMonacoLoaded, setIsMonacoLoaded }),
|
||||
[isMonacoLoaded]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<CardTitle
|
||||
title="jwt_claims.title"
|
||||
subtitle="jwt_claims.description"
|
||||
className={styles.header}
|
||||
/>
|
||||
<TabNav className={styles.tabNav}>
|
||||
{Object.values(LogtoJwtTokenPath).map((tokenType) => (
|
||||
<TabNavItem key={tokenType} href={getPagePath(tokenType)} isActive={tokenType === tab}>
|
||||
{t(`jwt_claims.${tabPhrases[tokenType]}`)}
|
||||
</TabNavItem>
|
||||
))}
|
||||
</TabNav>
|
||||
{(isLoading || !isMonacoLoaded) && <PageLoadingSkeleton tokenType={tab} />}
|
||||
|
||||
{!isLoading && (
|
||||
<CodeEditorLoadingContext.Provider value={codeEditorContextValue}>
|
||||
<Main tab={tab} {...rest} className={isMonacoLoaded ? undefined : styles.hidden} />
|
||||
</CodeEditorLoadingContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/no-unused-modules -- will update this later
|
||||
export default withAppInsights(JwtClaims);
|
|
@ -1,11 +0,0 @@
|
|||
import type { LogtoJwtTokenPath } from '@logto/schemas';
|
||||
|
||||
export type JwtClaimsFormType = {
|
||||
tokenType: LogtoJwtTokenPath;
|
||||
script?: string;
|
||||
environmentVariables?: Array<{ key: string; value: string }>;
|
||||
testSample?: {
|
||||
contextSample?: string;
|
||||
tokenSample?: string;
|
||||
};
|
||||
};
|
|
@ -1,67 +0,0 @@
|
|||
import {
|
||||
LogtoJwtTokenPath,
|
||||
type AccessTokenJwtCustomizer,
|
||||
type ClientCredentialsJwtCustomizer,
|
||||
} from '@logto/schemas';
|
||||
import { type ResponseError } from '@withtyped/client';
|
||||
import { useMemo } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useSwrFetcher from '@/hooks/use-swr-fetcher';
|
||||
import { shouldRetryOnError } from '@/utils/request';
|
||||
|
||||
import { getApiPath } from './utils/format';
|
||||
|
||||
function useJwtCustomizer() {
|
||||
const fetchApi = useApi({ hideErrorToast: true });
|
||||
const accessTokenFetcher = useSwrFetcher<AccessTokenJwtCustomizer>(fetchApi);
|
||||
const clientCredentialsFetcher = useSwrFetcher<ClientCredentialsJwtCustomizer>(fetchApi);
|
||||
|
||||
const {
|
||||
data: accessTokenJwtCustomizer,
|
||||
mutate: mutateAccessTokenJwtCustomizer,
|
||||
isLoading: isAccessTokenJwtDataLoading,
|
||||
error: accessTokenError,
|
||||
} = useSWR<AccessTokenJwtCustomizer, ResponseError>(getApiPath(LogtoJwtTokenPath.AccessToken), {
|
||||
fetcher: accessTokenFetcher,
|
||||
shouldRetryOnError: shouldRetryOnError({ ignore: [404] }),
|
||||
});
|
||||
|
||||
const {
|
||||
data: clientCredentialsJwtCustomizer,
|
||||
mutate: mutateClientCredentialsJwtCustomizer,
|
||||
isLoading: isClientCredentialsJwtDataLoading,
|
||||
error: clientCredentialsError,
|
||||
} = useSWR<ClientCredentialsJwtCustomizer, ResponseError>(
|
||||
getApiPath(LogtoJwtTokenPath.ClientCredentials),
|
||||
{
|
||||
fetcher: clientCredentialsFetcher,
|
||||
shouldRetryOnError: shouldRetryOnError({ ignore: [404] }),
|
||||
}
|
||||
);
|
||||
|
||||
// Show global loading status only if any of the fetchers are loading and no errors are present
|
||||
const isLoading =
|
||||
(isAccessTokenJwtDataLoading && !accessTokenError) ||
|
||||
(isClientCredentialsJwtDataLoading && !clientCredentialsError);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
accessTokenJwtCustomizer,
|
||||
clientCredentialsJwtCustomizer,
|
||||
isLoading,
|
||||
mutateAccessTokenJwtCustomizer,
|
||||
mutateClientCredentialsJwtCustomizer,
|
||||
}),
|
||||
[
|
||||
accessTokenJwtCustomizer,
|
||||
clientCredentialsJwtCustomizer,
|
||||
isLoading,
|
||||
mutateAccessTokenJwtCustomizer,
|
||||
mutateClientCredentialsJwtCustomizer,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export default useJwtCustomizer;
|
|
@ -1,117 +0,0 @@
|
|||
import {
|
||||
LogtoJwtTokenPath,
|
||||
type AccessTokenJwtCustomizer,
|
||||
type ClientCredentialsJwtCustomizer,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import type { JwtClaimsFormType } from '../type';
|
||||
|
||||
import {
|
||||
defaultAccessTokenPayload,
|
||||
defaultClientCredentialsPayload,
|
||||
defaultUserTokenContextData,
|
||||
} from './config';
|
||||
|
||||
const formatEnvVariablesResponseToFormData = (
|
||||
enVariables?: AccessTokenJwtCustomizer['envVars']
|
||||
) => {
|
||||
if (!enVariables) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Object.entries(enVariables).map(([key, value]) => ({ key, value }));
|
||||
};
|
||||
|
||||
const formatSampleCodeJsonToString = (sampleJson?: AccessTokenJwtCustomizer['contextSample']) => {
|
||||
if (!sampleJson) {
|
||||
return;
|
||||
}
|
||||
|
||||
return JSON.stringify(sampleJson, null, 2);
|
||||
};
|
||||
|
||||
export const formatResponseDataToFormData = <T extends LogtoJwtTokenPath>(
|
||||
tokenType: T,
|
||||
data?: T extends LogtoJwtTokenPath.AccessToken
|
||||
? AccessTokenJwtCustomizer
|
||||
: ClientCredentialsJwtCustomizer
|
||||
): JwtClaimsFormType => {
|
||||
return {
|
||||
script: data?.script ?? '', // React-hook-form won't mutate the value if it's undefined
|
||||
tokenType,
|
||||
environmentVariables: formatEnvVariablesResponseToFormData(data?.envVars) ?? [
|
||||
{ key: '', value: '' },
|
||||
],
|
||||
testSample: {
|
||||
tokenSample: formatSampleCodeJsonToString(data?.tokenSample),
|
||||
// Technically, contextSample is always undefined for client credentials token type
|
||||
contextSample: formatSampleCodeJsonToString(data?.contextSample),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const formatEnvVariablesFormData = (envVariables: JwtClaimsFormType['environmentVariables']) => {
|
||||
if (!envVariables) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = envVariables.filter(({ key, value }) => key && value);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Object.fromEntries(entries.map(({ key, value }) => [key, value]));
|
||||
};
|
||||
|
||||
const formatSampleCodeStringToJson = (sampleCode?: string) => {
|
||||
if (!sampleCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax -- guarded by back-end validation
|
||||
return JSON.parse(sampleCode) as Record<string, unknown>;
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const formatFormDataToRequestData = (data: JwtClaimsFormType) => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- parse empty string as undefined
|
||||
script: data.script || undefined,
|
||||
envVars: formatEnvVariablesFormData(data.environmentVariables),
|
||||
tokenSample: formatSampleCodeStringToJson(data.testSample?.tokenSample),
|
||||
// Technically, contextSample is always undefined for client credentials token type
|
||||
contextSample: formatSampleCodeStringToJson(data.testSample?.contextSample),
|
||||
};
|
||||
};
|
||||
|
||||
export const formatFormDataToTestRequestPayload = ({
|
||||
tokenType,
|
||||
script,
|
||||
environmentVariables,
|
||||
testSample,
|
||||
}: JwtClaimsFormType) => {
|
||||
const defaultTokenSample =
|
||||
tokenType === LogtoJwtTokenPath.AccessToken
|
||||
? defaultAccessTokenPayload
|
||||
: defaultClientCredentialsPayload;
|
||||
|
||||
const defaultContextSample =
|
||||
tokenType === LogtoJwtTokenPath.AccessToken ? defaultUserTokenContextData : undefined;
|
||||
|
||||
return {
|
||||
tokenType,
|
||||
payload: {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- parse empty string as undefined
|
||||
script: script || undefined,
|
||||
envVars: formatEnvVariablesFormData(environmentVariables),
|
||||
tokenSample: formatSampleCodeStringToJson(testSample?.tokenSample) ?? defaultTokenSample,
|
||||
contextSample:
|
||||
formatSampleCodeStringToJson(testSample?.contextSample) ?? defaultContextSample,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const getApiPath = (tokenType: LogtoJwtTokenPath) =>
|
||||
`api/configs/jwt-customizer/${tokenType}`;
|
|
@ -1,6 +1,6 @@
|
|||
const jwt_claims = {
|
||||
/** UNTRANSLATED */
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
/** UNTRANSLATED */
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
|
|
|
@ -15,7 +15,7 @@ const tabs = {
|
|||
tenant_settings: 'Einstellungen',
|
||||
mfa: 'Multi-Faktor-Authentifizierung',
|
||||
/** UNTRANSLATED */
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: 'Signierschlüssel',
|
||||
organization_template: 'Organisationstemplate',
|
||||
};
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const jwt_claims = {
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
user_jwt: {
|
||||
|
|
|
@ -14,7 +14,7 @@ const tabs = {
|
|||
docs: 'Docs',
|
||||
tenant_settings: 'Settings',
|
||||
mfa: 'Multi-factor auth',
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: 'Signing keys',
|
||||
organization_template: 'Organization template',
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const jwt_claims = {
|
||||
/** UNTRANSLATED */
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
/** UNTRANSLATED */
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
|
|
|
@ -15,7 +15,7 @@ const tabs = {
|
|||
tenant_settings: 'Configuraciones del inquilino',
|
||||
mfa: 'Autenticación multifactor',
|
||||
/** UNTRANSLATED */
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: 'Claves de firma',
|
||||
organization_template: 'Plantilla de organización',
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const jwt_claims = {
|
||||
/** UNTRANSLATED */
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
/** UNTRANSLATED */
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
|
|
|
@ -15,7 +15,7 @@ const tabs = {
|
|||
tenant_settings: 'Paramètres du locataire',
|
||||
mfa: 'Authentification multi-facteur',
|
||||
/** UNTRANSLATED */
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: 'Clés de signature',
|
||||
organization_template: "Modèle d'organisation",
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const jwt_claims = {
|
||||
/** UNTRANSLATED */
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
/** UNTRANSLATED */
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
|
|
|
@ -15,7 +15,7 @@ const tabs = {
|
|||
tenant_settings: 'Impostazioni',
|
||||
mfa: 'Autenticazione multi-fattore',
|
||||
/** UNTRANSLATED */
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: 'Chiavi di firma',
|
||||
organization_template: 'Modello di organizzazione',
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const jwt_claims = {
|
||||
/** UNTRANSLATED */
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
/** UNTRANSLATED */
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
|
|
|
@ -15,7 +15,7 @@ const tabs = {
|
|||
tenant_settings: '設定',
|
||||
mfa: 'Multi-factor auth',
|
||||
/** UNTRANSLATED */
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: '署名キー',
|
||||
organization_template: '組織テンプレート',
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const jwt_claims = {
|
||||
/** UNTRANSLATED */
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
/** UNTRANSLATED */
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
|
|
|
@ -15,7 +15,7 @@ const tabs = {
|
|||
tenant_settings: '테넌트 설정',
|
||||
mfa: '다중 요소 인증',
|
||||
/** UNTRANSLATED */
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: '서명 키',
|
||||
organization_template: '조직 템플릿',
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const jwt_claims = {
|
||||
/** UNTRANSLATED */
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
/** UNTRANSLATED */
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
|
|
|
@ -15,7 +15,7 @@ const tabs = {
|
|||
tenant_settings: 'Ustawienia',
|
||||
mfa: 'Multi-factor auth',
|
||||
/** UNTRANSLATED */
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: 'Klucze do podpisu',
|
||||
organization_template: 'Szablon organizacji',
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const jwt_claims = {
|
||||
/** UNTRANSLATED */
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
/** UNTRANSLATED */
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
|
|
|
@ -15,7 +15,7 @@ const tabs = {
|
|||
tenant_settings: 'Configurações',
|
||||
mfa: 'Autenticação de multi-fator',
|
||||
/** UNTRANSLATED */
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: 'Chaves de assinatura',
|
||||
organization_template: 'Modelo de organização',
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const jwt_claims = {
|
||||
/** UNTRANSLATED */
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
/** UNTRANSLATED */
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
|
|
|
@ -15,7 +15,7 @@ const tabs = {
|
|||
tenant_settings: 'Definições do inquilino',
|
||||
mfa: 'Autenticação multi-fator',
|
||||
/** UNTRANSLATED */
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: 'Chaves de assinatura',
|
||||
organization_template: 'Modelo de organização',
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const jwt_claims = {
|
||||
/** UNTRANSLATED */
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
/** UNTRANSLATED */
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
|
|
|
@ -15,7 +15,7 @@ const tabs = {
|
|||
tenant_settings: 'Настройки',
|
||||
mfa: 'Multi-factor auth',
|
||||
/** UNTRANSLATED */
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: 'Ключи подписи',
|
||||
organization_template: 'Шаблон организации',
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const jwt_claims = {
|
||||
/** UNTRANSLATED */
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
/** UNTRANSLATED */
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
|
|
|
@ -15,7 +15,7 @@ const tabs = {
|
|||
tenant_settings: 'Ayarlar',
|
||||
mfa: 'Çoklu faktörlü kimlik doğrulama',
|
||||
/** UNTRANSLATED */
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: 'İmza anahtarları',
|
||||
organization_template: 'Kuruluş şablonu',
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const jwt_claims = {
|
||||
/** UNTRANSLATED */
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
/** UNTRANSLATED */
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
|
|
|
@ -15,7 +15,7 @@ const tabs = {
|
|||
tenant_settings: '租户设置',
|
||||
mfa: '多因素认证',
|
||||
/** UNTRANSLATED */
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: '签名密钥',
|
||||
organization_template: '组织模板',
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const jwt_claims = {
|
||||
/** UNTRANSLATED */
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
/** UNTRANSLATED */
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
|
|
|
@ -15,7 +15,7 @@ const tabs = {
|
|||
tenant_settings: '租戶設置',
|
||||
mfa: '多重認證',
|
||||
/** UNTRANSLATED */
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: '簽署密鑰',
|
||||
organization_template: '組織模板',
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const jwt_claims = {
|
||||
/** UNTRANSLATED */
|
||||
title: 'JWT claims',
|
||||
title: 'Custom JWT',
|
||||
/** UNTRANSLATED */
|
||||
description:
|
||||
'Set up custom JWT claims to include in the access token. These claims can be used to pass additional information to your application.',
|
||||
|
|
|
@ -15,7 +15,7 @@ const tabs = {
|
|||
tenant_settings: '租戶設定',
|
||||
mfa: '多重認證',
|
||||
/** UNTRANSLATED */
|
||||
jwt_customizer: 'JWT Claims',
|
||||
customize_jwt: 'JWT Claims',
|
||||
signing_keys: '簽署密鑰',
|
||||
organization_template: '組織模板',
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue