0
Fork 0
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:
Darcy Ye 2024-04-01 16:42:55 +08:00 committed by GitHub
commit 39868d5fa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 569 additions and 627 deletions

View file

@ -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,
},
{

View file

@ -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>

View file

@ -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());

View file

@ -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

View file

@ -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]);

View file

@ -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';
}

View file

@ -0,0 +1 @@
export type Action = 'create' | 'edit';

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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',
});

View file

@ -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 (

View file

@ -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 {

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -17,7 +17,6 @@ export const defaultOptions: EditorProps['options'] = {
minimap: {
enabled: false,
},
wordWrap: 'on',
renderLineHighlight: 'none',
fontFamily: 'Roboto Mono, monospace',
fontSize: 14,

View file

@ -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={() => {

View file

@ -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;

View file

@ -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}>

View file

@ -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;
}
}

View 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);

View 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')]),
});

View file

@ -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;

View file

@ -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: [

View 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,
},
};
};

View file

@ -0,0 +1,4 @@
import { type LogtoJwtTokenPath } from '@logto/schemas';
export const getApiPath = (tokenType: LogtoJwtTokenPath) =>
`api/configs/jwt-customizer/${tokenType}`;

View file

@ -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
? `{

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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);

View file

@ -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;
};
};

View file

@ -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;

View file

@ -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}`;

View file

@ -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.',

View file

@ -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',
};

View file

@ -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: {

View file

@ -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',
};

View file

@ -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.',

View file

@ -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',
};

View file

@ -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.',

View file

@ -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",
};

View file

@ -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.',

View file

@ -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',
};

View file

@ -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.',

View file

@ -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: '組織テンプレート',
};

View file

@ -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.',

View file

@ -15,7 +15,7 @@ const tabs = {
tenant_settings: '테넌트 설정',
mfa: '다중 요소 인증',
/** UNTRANSLATED */
jwt_customizer: 'JWT Claims',
customize_jwt: 'JWT Claims',
signing_keys: '서명 키',
organization_template: '조직 템플릿',
};

View file

@ -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.',

View file

@ -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',
};

View file

@ -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.',

View file

@ -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',
};

View file

@ -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.',

View file

@ -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',
};

View file

@ -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.',

View file

@ -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: 'Шаблон организации',
};

View file

@ -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.',

View file

@ -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',
};

View file

@ -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.',

View file

@ -15,7 +15,7 @@ const tabs = {
tenant_settings: '租户设置',
mfa: '多因素认证',
/** UNTRANSLATED */
jwt_customizer: 'JWT Claims',
customize_jwt: 'JWT Claims',
signing_keys: '签名密钥',
organization_template: '组织模板',
};

View file

@ -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.',

View file

@ -15,7 +15,7 @@ const tabs = {
tenant_settings: '租戶設置',
mfa: '多重認證',
/** UNTRANSLATED */
jwt_customizer: 'JWT Claims',
customize_jwt: 'JWT Claims',
signing_keys: '簽署密鑰',
organization_template: '組織模板',
};

View file

@ -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.',

View file

@ -15,7 +15,7 @@ const tabs = {
tenant_settings: '租戶設定',
mfa: '多重認證',
/** UNTRANSLATED */
jwt_customizer: 'JWT Claims',
customize_jwt: 'JWT Claims',
signing_keys: '簽署密鑰',
organization_template: '組織模板',
};