0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

feat(console,phrases): integrate jwt customizer api (#5495)

* feat(console,phrases): integrate jwt customizer api

integrate jwt customizer api

* chore(console): update the comment

update the comment

* fix(console): clear the console logs

clear the console logs
This commit is contained in:
simeng-li 2024-03-13 14:21:08 +08:00 committed by GitHub
parent 2c5a4491ae
commit f11e95e1aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 367 additions and 61 deletions

View file

@ -206,7 +206,7 @@ function ConsoleContent() {
</Route>
)}
{isDevFeaturesEnabled && (
<Route path="jwt-claims">
<Route path="jwt-customizer">
<Route index element={<Navigate replace to={LogtoJwtTokenPath.AccessToken} />} />
<Route
path={LogtoJwtTokenPath.AccessToken}

View file

@ -0,0 +1,106 @@
import {
type JwtCustomizerAccessToken,
LogtoJwtTokenPath,
type JwtCustomizerClientCredentials,
} 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';
type Props = {
tab: LogtoJwtTokenPath;
accessTokenJwtCustomizer: JwtCustomizerAccessToken | undefined;
clientCredentialsJwtCustomizer: JwtCustomizerClientCredentials | undefined;
mutateAccessTokenJwtCustomizer: KeyedMutator<JwtCustomizerAccessToken>;
mutateClientCredentialsJwtCustomizer: KeyedMutator<JwtCustomizerClientCredentials>;
};
function Main({
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)}>
<ScriptSection />
<SettingsSection />
</form>
</FormProvider>
<SubmitFormChangesActionBar
isOpen={isDirty}
isSubmitting={isSubmitting}
onDiscard={reset}
onSubmit={onSubmitHandler}
/>
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty && !isSubmitting} />
</>
);
}
export default Main;

View file

@ -50,7 +50,6 @@ function MonacoCodeEditor({
}: Props) {
const monaco = useMonaco();
const editorRef = useRef<Nullable<IStandaloneCodeEditor>>(null);
console.log('code', value);
const activeModel = useMemo(
() => activeModelName && models.find((model) => model.name === activeModelName),

View file

@ -26,6 +26,7 @@ function ScriptSection() {
() => (tokenType === LogtoJwtTokenPath.AccessToken ? userJwtFile : machineToMachineJwtFile),
[tokenType]
);
return (
<Card className={styles.codePanel}>
<div className={styles.cardTitle}>
@ -37,10 +38,9 @@ function ScriptSection() {
// Force rerender the controller when the token type changes
// Otherwise the input field will not be updated
key={tokenType}
shouldUnregister
control={control}
name="script"
render={({ field: { onChange, value } }) => (
render={({ field: { onChange, value }, formState: { defaultValues } }) => (
<MonacoCodeEditor
className={styles.flexGrow}
enabledActions={['clear', 'copy']}
@ -48,6 +48,12 @@ function ScriptSection() {
activeModelName={activeModel.name}
value={value}
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;
}
onChange(newValue);
}}
/>

View file

@ -63,7 +63,7 @@ function EnvironmentVariablesField() {
const valueValidator = useCallback(
(value: string, index: number) => {
return getValues(`environmentVariables.${index}.value`)
return getValues(`environmentVariables.${index}.key`)
? Boolean(value) || t('webhook_details.settings.value_missing_error')
: true;
},

View file

@ -30,7 +30,7 @@ function TestTab({ isActive }: Props) {
const [testResult, setTestResult] = useState<TestResultData>();
const [activeModelName, setActiveModelName] = useState<string>();
const { watch, control } = useFormContext<JwtClaimsFormType>();
const { watch, control, formState } = useFormContext<JwtClaimsFormType>();
const tokenType = watch('tokenType');
const editorModels = useMemo(
@ -78,6 +78,27 @@ function TestTab({ isActive }: Props) {
[activeModelName]
);
const validateSampleCode = useCallback(
(value: JwtClaimsFormType['testSample']) => {
if (!value) {
return true;
}
for (const [_, sampleCode] of Object.entries(value)) {
if (sampleCode) {
try {
JSON.parse(sampleCode);
} catch {
return t('form_error.invalid_json');
}
}
}
return true;
},
[t]
);
return (
<div className={classNames(styles.tabContent, isActive && styles.active)}>
<Card className={classNames(styles.card, styles.flexGrow, styles.flexColumn)}>
@ -89,13 +110,18 @@ function TestTab({ isActive }: Props) {
<Button title="jwt_claims.tester.run_button" type="primary" onClick={onTestHandler} />
</div>
<div className={classNames(styles.cardContent, styles.flexColumn, styles.flexGrow)}>
{formState.errors.testSample && (
<div className={styles.error}>{formState.errors.testSample.message}</div>
)}
<Controller
// Force rerender the controller when the token type changes
// Otherwise the input field will not be updated
key={tokenType}
shouldUnregister
control={control}
name="testSample"
rules={{
validate: validateSampleCode,
}}
render={({ field }) => (
<MonacoCodeEditor
className={styles.flexGrow}
@ -108,7 +134,6 @@ function TestTab({ isActive }: Props) {
/>
)}
/>
{testResult && (
<TestResult
testResult={testResult}

View file

@ -180,6 +180,11 @@
}
}
.error {
margin: _.unit(4) 0;
color: var(--color-error);
}
.flexColumn {
display: flex;
flex-direction: column;

View file

@ -1,64 +1,29 @@
import { withAppInsights } from '@logto/app-insights/react/AppInsightsReact';
import { LogtoJwtTokenPath } from '@logto/schemas';
import classNames from 'classnames';
import { useMemo } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import SubmitFormChangesActionBar from '@/components/SubmitFormChangesActionBar';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import CardTitle from '@/ds-components/CardTitle';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import ScriptSection from './ScriptSection';
import SettingsSection from './SettingsSection';
import Main from './Main';
import * as styles from './index.module.scss';
import { type JwtClaimsFormType } from './type';
import useJwtCustomizer from './use-jwt-customizer';
const tabPhrases = Object.freeze({
[LogtoJwtTokenPath.AccessToken]: 'user_jwt_tab',
[LogtoJwtTokenPath.ClientCredentials]: 'machine_to_machine_jwt_tab',
});
const getPath = (tab: LogtoJwtTokenPath) => `/jwt-claims/${tab}`;
const getPagePath = (tokenType: LogtoJwtTokenPath) => `/jwt-customizer/${tokenType}`;
type Props = {
tab: LogtoJwtTokenPath;
};
// TODO: API integration
function JwtClaims({ tab }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const userJwtClaimsForm = useForm<JwtClaimsFormType>({
defaultValues: {
tokenType: LogtoJwtTokenPath.AccessToken,
environmentVariables: [{ key: '', value: '' }],
},
});
const machineToMachineJwtClaimsForm = useForm<JwtClaimsFormType>({
defaultValues: {
tokenType: LogtoJwtTokenPath.ClientCredentials,
environmentVariables: [{ key: '', value: '' }],
},
});
const activeForm = useMemo(
() =>
tab === LogtoJwtTokenPath.AccessToken ? userJwtClaimsForm : machineToMachineJwtClaimsForm,
[machineToMachineJwtClaimsForm, tab, userJwtClaimsForm]
);
const {
formState: { isDirty, isSubmitting },
reset,
handleSubmit,
} = activeForm;
const onSubmitHandler = handleSubmit(async (data) => {
// TODO: API integration
});
const { isLoading, ...rest } = useJwtCustomizer();
return (
<div className={styles.container}>
@ -69,24 +34,13 @@ function JwtClaims({ tab }: Props) {
/>
<TabNav className={styles.tabNav}>
{Object.values(LogtoJwtTokenPath).map((tokenType) => (
<TabNavItem key={tokenType} href={getPath(tokenType)} isActive={tokenType === tab}>
<TabNavItem key={tokenType} href={getPagePath(tokenType)} isActive={tokenType === tab}>
{t(`jwt_claims.${tabPhrases[tokenType]}`)}
</TabNavItem>
))}
</TabNav>
<FormProvider {...activeForm}>
<form className={classNames(styles.tabContent)}>
<ScriptSection />
<SettingsSection />
</form>
</FormProvider>
<SubmitFormChangesActionBar
isOpen={isDirty}
isSubmitting={isSubmitting}
onDiscard={reset}
onSubmit={onSubmitHandler}
/>
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty && !isSubmitting} />
{/* TODO: Loading skelton */}
{!isLoading && <Main tab={tab} {...rest} />}
</div>
);
}

View file

@ -0,0 +1,67 @@
import {
LogtoJwtTokenPath,
type JwtCustomizerAccessToken,
type JwtCustomizerClientCredentials,
} 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';
function useJwtCustomizer() {
const fetchApi = useApi({ hideErrorToast: true });
const accessTokenFetcher = useSwrFetcher<JwtCustomizerAccessToken>(fetchApi);
const clientCredentialsFetcher = useSwrFetcher<JwtCustomizerClientCredentials>(fetchApi);
const {
data: accessTokenJwtCustomizer,
mutate: mutateAccessTokenJwtCustomizer,
isLoading: isAccessTokenJwtDataLoading,
error: accessTokenError,
} = useSWR<JwtCustomizerAccessToken, ResponseError>(getApiPath(LogtoJwtTokenPath.AccessToken), {
fetcher: accessTokenFetcher,
shouldRetryOnError: shouldRetryOnError({ ignore: [404] }),
});
const {
data: clientCredentialsJwtCustomizer,
mutate: mutateClientCredentialsJwtCustomizer,
isLoading: isClientCredentialsJwtDataLoading,
error: clientCredentialsError,
} = useSWR<JwtCustomizerClientCredentials, 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

@ -0,0 +1,85 @@
import {
type LogtoJwtTokenPath,
type JwtCustomizerAccessToken,
type JwtCustomizerClientCredentials,
} from '@logto/schemas';
import type { JwtClaimsFormType } from './type';
const formatEnvVariablesResponseToFormData = (
enVariables?: JwtCustomizerAccessToken['envVars']
) => {
if (!enVariables) {
return;
}
return Object.entries(enVariables).map(([key, value]) => ({ key, value }));
};
const formatSampleCodeJsonToString = (
sampleJson?: JwtCustomizerAccessToken['contextSample'] | JwtCustomizerAccessToken['tokenSample']
) => {
if (!sampleJson) {
return;
}
return JSON.stringify(sampleJson, null, 2);
};
export const formatResponseDataToFormData = <T extends LogtoJwtTokenPath>(
tokenType: T,
data?: T extends LogtoJwtTokenPath.AccessToken
? JwtCustomizerAccessToken
: JwtCustomizerClientCredentials
): JwtClaimsFormType => {
return {
script: data?.script,
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 {
script: data.script,
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 getApiPath = (tokenType: LogtoJwtTokenPath) =>
`api/configs/jwt-customizer/${tokenType}`;

View file

@ -74,6 +74,10 @@ const jwt_claims = {
/** UNTRANSLATED */
result_title: 'Test result',
},
form_error: {
/** UNTRANSLATED */
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);

View file

@ -45,6 +45,9 @@ const jwt_claims = {
run_button: 'Run',
result_title: 'Test result',
},
form_error: {
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);

View file

@ -74,6 +74,10 @@ const jwt_claims = {
/** UNTRANSLATED */
result_title: 'Test result',
},
form_error: {
/** UNTRANSLATED */
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);

View file

@ -74,6 +74,10 @@ const jwt_claims = {
/** UNTRANSLATED */
result_title: 'Test result',
},
form_error: {
/** UNTRANSLATED */
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);

View file

@ -74,6 +74,10 @@ const jwt_claims = {
/** UNTRANSLATED */
result_title: 'Test result',
},
form_error: {
/** UNTRANSLATED */
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);

View file

@ -74,6 +74,10 @@ const jwt_claims = {
/** UNTRANSLATED */
result_title: 'Test result',
},
form_error: {
/** UNTRANSLATED */
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);

View file

@ -74,6 +74,10 @@ const jwt_claims = {
/** UNTRANSLATED */
result_title: 'Test result',
},
form_error: {
/** UNTRANSLATED */
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);

View file

@ -74,6 +74,10 @@ const jwt_claims = {
/** UNTRANSLATED */
result_title: 'Test result',
},
form_error: {
/** UNTRANSLATED */
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);

View file

@ -74,6 +74,10 @@ const jwt_claims = {
/** UNTRANSLATED */
result_title: 'Test result',
},
form_error: {
/** UNTRANSLATED */
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);

View file

@ -74,6 +74,10 @@ const jwt_claims = {
/** UNTRANSLATED */
result_title: 'Test result',
},
form_error: {
/** UNTRANSLATED */
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);

View file

@ -74,6 +74,10 @@ const jwt_claims = {
/** UNTRANSLATED */
result_title: 'Test result',
},
form_error: {
/** UNTRANSLATED */
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);

View file

@ -74,6 +74,10 @@ const jwt_claims = {
/** UNTRANSLATED */
result_title: 'Test result',
},
form_error: {
/** UNTRANSLATED */
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);

View file

@ -74,6 +74,10 @@ const jwt_claims = {
/** UNTRANSLATED */
result_title: 'Test result',
},
form_error: {
/** UNTRANSLATED */
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);

View file

@ -74,6 +74,10 @@ const jwt_claims = {
/** UNTRANSLATED */
result_title: 'Test result',
},
form_error: {
/** UNTRANSLATED */
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);

View file

@ -74,6 +74,10 @@ const jwt_claims = {
/** UNTRANSLATED */
result_title: 'Test result',
},
form_error: {
/** UNTRANSLATED */
invalid_json: 'Invalid JSON format',
},
};
export default Object.freeze(jwt_claims);