0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

fix(console): fix application refresh token settings (#5244)

This commit is contained in:
Xiao Yijun 2024-01-17 15:05:30 +08:00 committed by GitHub
parent d04bc9d19d
commit 61e5d7f21c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 272 additions and 250 deletions

View file

@ -0,0 +1,231 @@
import {
ApplicationType,
type Application,
type ApplicationResponse,
type SnakeCaseOidcConfig,
} from '@logto/schemas';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import Delete from '@/assets/icons/delete.svg';
import File from '@/assets/icons/file.svg';
import ApplicationIcon from '@/components/ApplicationIcon';
import DetailsForm from '@/components/DetailsForm';
import DetailsPageHeader from '@/components/DetailsPage/DetailsPageHeader';
import Drawer from '@/components/Drawer';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { ApplicationDetailsTabs } from '@/consts';
import { isDevFeaturesEnabled } from '@/consts/env';
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import TabWrapper from '@/ds-components/TabWrapper';
import useApi from '@/hooks/use-api';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import { applicationTypeI18nKey } from '@/types/applications';
import { trySubmitSafe } from '@/utils/form';
import Branding from '../components/Branding';
import EndpointsAndCredentials from '../components/EndpointsAndCredentials';
import GuideDrawer from '../components/GuideDrawer';
import MachineLogs from '../components/MachineLogs';
import MachineToMachineApplicationRoles from '../components/MachineToMachineApplicationRoles';
import Permissions from '../components/Permissions';
import RefreshTokenSettings from '../components/RefreshTokenSettings';
import Settings from '../components/Settings';
import * as styles from '../index.module.scss';
import { type ApplicationForm, applicationFormDataParser } from '../utils';
type Props = {
data: ApplicationResponse;
oidcConfig: SnakeCaseOidcConfig;
onApplicationUpdated: () => void;
};
function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { tab } = useParams();
const { navigate } = useTenantPathname();
const formMethods = useForm<ApplicationForm>({
defaultValues: applicationFormDataParser.fromResponse(data),
});
const {
handleSubmit,
reset,
formState: { isSubmitting, isDirty },
} = formMethods;
const [isReadmeOpen, setIsReadmeOpen] = useState(false);
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleted, setIsDeleted] = useState(false);
const api = useApi();
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
if (isSubmitting) {
return;
}
await api
.patch(`api/applications/${data.id}`, {
json: applicationFormDataParser.toUpdateApplicationData(formData),
})
.json<Application>();
reset(formData);
onApplicationUpdated();
toast.success(t('general.saved'));
})
);
const onDelete = async () => {
if (isDeleting) {
return;
}
try {
await api.delete(`api/applications/${data.id}`);
setIsDeleted(true);
setIsDeleting(false);
setIsDeleteFormOpen(false);
toast.success(t('application_details.application_deleted', { name: data.name }));
navigate(`/applications`);
} catch {
setIsDeleting(false);
}
};
const onCloseDrawer = () => {
setIsReadmeOpen(false);
};
return (
<>
<DetailsPageHeader
icon={<ApplicationIcon type={data.type} />}
title={data.name}
primaryTag={t(`${applicationTypeI18nKey[data.type]}.title`)}
identifier={{ name: 'App ID', value: data.id }}
additionalActionButton={{
title: 'application_details.check_guide',
icon: <File />,
onClick: () => {
setIsReadmeOpen(true);
},
}}
actionMenuItems={[
{
type: 'danger',
title: 'general.delete',
icon: <Delete />,
onClick: () => {
setIsDeleteFormOpen(true);
},
},
]}
/>
<Drawer isOpen={isReadmeOpen} onClose={onCloseDrawer}>
<GuideDrawer app={data} onClose={onCloseDrawer} />
</Drawer>
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
isLoading={isDeleting}
expectedInput={data.name}
inputPlaceholder={t('application_details.enter_your_application_name')}
className={styles.deleteConfirm}
onCancel={() => {
setIsDeleteFormOpen(false);
}}
onConfirm={onDelete}
>
<div className={styles.description}>
<Trans components={{ span: <span className={styles.highlight} /> }}>
{t('application_details.delete_description', { name: data.name })}
</Trans>
</div>
</DeleteConfirmModal>
<TabNav>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Settings}`}>
{t('application_details.settings')}
</TabNavItem>
{data.type === ApplicationType.MachineToMachine && (
<>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Roles}`}>
{t('application_details.application_roles')}
</TabNavItem>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Logs}`}>
{t('application_details.machine_logs')}
</TabNavItem>
</>
)}
{isDevFeaturesEnabled && data.isThirdParty && (
<>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Permissions}`}>
{t('application_details.permissions.name')}
</TabNavItem>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Branding}`}>
{t('application_details.branding.name')}
</TabNavItem>
</>
)}
</TabNav>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Settings}
className={styles.tabContainer}
>
<FormProvider {...formMethods}>
<DetailsForm
isDirty={isDirty}
isSubmitting={isSubmitting}
onDiscard={reset}
onSubmit={onSubmit}
>
<Settings data={data} />
<EndpointsAndCredentials app={data} oidcConfig={oidcConfig} />
{data.type !== ApplicationType.MachineToMachine && <RefreshTokenSettings data={data} />}
</DetailsForm>
</FormProvider>
</TabWrapper>
{data.type === ApplicationType.MachineToMachine && (
<>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Roles}
className={styles.tabContainer}
>
<MachineToMachineApplicationRoles application={data} />
</TabWrapper>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Logs}
className={styles.tabContainer}
>
<MachineLogs applicationId={data.id} />
</TabWrapper>
</>
)}
{isDevFeaturesEnabled && data.isThirdParty && (
<>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Permissions}
className={styles.tabContainer}
>
<Permissions application={data} />
</TabWrapper>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Branding}
className={styles.tabContainer}
>
<Branding application={data} />
</TabWrapper>
</>
)}
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} onConfirm={reset} />
</>
);
}
export default ApplicationDetailsContent;

View file

@ -37,4 +37,3 @@
display: flex;
}
}

View file

@ -1,63 +1,22 @@
import { withAppInsights } from '@logto/app-insights/react';
import {
type Application,
type ApplicationResponse,
type SnakeCaseOidcConfig,
customClientMetadataDefault,
ApplicationType,
} from '@logto/schemas';
import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import { type ApplicationResponse, type SnakeCaseOidcConfig } from '@logto/schemas';
import { useParams } from 'react-router-dom';
import useSWR from 'swr';
import Delete from '@/assets/icons/delete.svg';
import File from '@/assets/icons/file.svg';
import ApplicationIcon from '@/components/ApplicationIcon';
import DetailsForm from '@/components/DetailsForm';
import DetailsPage from '@/components/DetailsPage';
import DetailsPageHeader from '@/components/DetailsPage/DetailsPageHeader';
import Drawer from '@/components/Drawer';
import PageMeta from '@/components/PageMeta';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { ApplicationDetailsTabs } from '@/consts';
import { isDevFeaturesEnabled } from '@/consts/env';
import { openIdProviderConfigPath } from '@/consts/oidc';
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import TabWrapper from '@/ds-components/TabWrapper';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import { applicationTypeI18nKey } from '@/types/applications';
import { trySubmitSafe } from '@/utils/form';
import Branding from './components/Branding';
import EndpointsAndCredentials from './components/EndpointsAndCredentials';
import GuideDrawer from './components/GuideDrawer';
import ApplicationDetailsContent from './ApplicationDetailsContent';
import GuideModal from './components/GuideModal';
import MachineLogs from './components/MachineLogs';
import MachineToMachineApplicationRoles from './components/MachineToMachineApplicationRoles';
import Permissions from './components/Permissions';
import RefreshTokenSettings from './components/RefreshTokenSettings';
import Settings from './components/Settings';
import * as styles from './index.module.scss';
import { type ApplicationForm, applicationFormDataParser } from './utils';
const mapToUriFormatArrays = (value?: string[]) =>
value?.filter(Boolean).map((uri) => decodeURIComponent(uri));
const mapToUriOriginFormatArrays = (value?: string[]) =>
value?.filter(Boolean).map((uri) => decodeURIComponent(uri.replace(/\/*$/, '')));
function ApplicationDetails() {
const { id, guideId, tab } = useParams();
const { id, guideId } = useParams();
const { navigate, match } = useTenantPathname();
const isGuideView = !!id && !!guideId && match(`/applications/${id}/guide/${guideId}`);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data, error, mutate } = useSWR<ApplicationResponse, RequestError>(
id && `api/applications/${id}`
);
@ -70,85 +29,6 @@ function ApplicationDetails() {
const isLoading = (!data && !error) || (!oidcConfig && !fetchOidcConfigError);
const requestError = error ?? fetchOidcConfigError;
const [isReadmeOpen, setIsReadmeOpen] = useState(false);
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleted, setIsDeleted] = useState(false);
const api = useApi();
const formMethods = useForm<ApplicationForm>({
defaultValues: { customClientMetadata: customClientMetadataDefault, isAdmin: false },
});
const {
handleSubmit,
reset,
formState: { isSubmitting, isDirty },
} = formMethods;
useEffect(() => {
if (!data) {
return;
}
if (isDirty) {
return;
}
reset(applicationFormDataParser.fromResponse(data));
}, [data, isDirty, reset]);
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
if (!data || isSubmitting) {
return;
}
await api
.patch(`api/applications/${data.id}`, {
json: {
...formData,
oidcClientMetadata: {
...formData.oidcClientMetadata,
redirectUris: mapToUriFormatArrays(formData.oidcClientMetadata.redirectUris),
postLogoutRedirectUris: mapToUriFormatArrays(
formData.oidcClientMetadata.postLogoutRedirectUris
),
},
customClientMetadata: {
...formData.customClientMetadata,
corsAllowedOrigins: mapToUriOriginFormatArrays(
formData.customClientMetadata.corsAllowedOrigins
),
},
},
})
.json<Application>();
reset(formData);
void mutate();
toast.success(t('general.saved'));
})
);
const onDelete = async () => {
if (!data || isDeleting) {
return;
}
try {
await api.delete(`api/applications/${data.id}`);
setIsDeleted(true);
setIsDeleting(false);
setIsDeleteFormOpen(false);
toast.success(t('application_details.application_deleted', { name: data.name }));
navigate(`/applications`);
} catch {
setIsDeleting(false);
}
};
const onCloseDrawer = () => {
setIsReadmeOpen(false);
};
if (isGuideView) {
return (
@ -175,131 +55,12 @@ function ApplicationDetails() {
>
<PageMeta titleKey="application_details.page_title" />
{data && oidcConfig && (
<>
<DetailsPageHeader
icon={<ApplicationIcon type={data.type} />}
title={data.name}
primaryTag={t(`${applicationTypeI18nKey[data.type]}.title`)}
identifier={{ name: 'App ID', value: data.id }}
additionalActionButton={{
title: 'application_details.check_guide',
icon: <File />,
onClick: () => {
setIsReadmeOpen(true);
},
}}
actionMenuItems={[
{
type: 'danger',
title: 'general.delete',
icon: <Delete />,
onClick: () => {
setIsDeleteFormOpen(true);
},
},
]}
/>
<Drawer isOpen={isReadmeOpen} onClose={onCloseDrawer}>
<GuideDrawer app={data} onClose={onCloseDrawer} />
</Drawer>
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
isLoading={isDeleting}
expectedInput={data.name}
inputPlaceholder={t('application_details.enter_your_application_name')}
className={styles.deleteConfirm}
onCancel={() => {
setIsDeleteFormOpen(false);
}}
onConfirm={onDelete}
>
<div className={styles.description}>
<Trans components={{ span: <span className={styles.highlight} /> }}>
{t('application_details.delete_description', { name: data.name })}
</Trans>
</div>
</DeleteConfirmModal>
<TabNav>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Settings}`}>
{t('application_details.settings')}
</TabNavItem>
{data.type === ApplicationType.MachineToMachine && (
<>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Roles}`}>
{t('application_details.application_roles')}
</TabNavItem>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Logs}`}>
{t('application_details.machine_logs')}
</TabNavItem>
</>
)}
{isDevFeaturesEnabled && data.isThirdParty && (
<>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Permissions}`}>
{t('application_details.permissions.name')}
</TabNavItem>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Branding}`}>
{t('application_details.branding.name')}
</TabNavItem>
</>
)}
</TabNav>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Settings}
className={styles.tabContainer}
>
<FormProvider {...formMethods}>
<DetailsForm
isDirty={isDirty}
isSubmitting={isSubmitting}
onDiscard={reset}
onSubmit={onSubmit}
>
<Settings data={data} />
<EndpointsAndCredentials app={data} oidcConfig={oidcConfig} />
{data.type !== ApplicationType.MachineToMachine && (
<RefreshTokenSettings data={data} />
)}
</DetailsForm>
</FormProvider>
</TabWrapper>
{data.type === ApplicationType.MachineToMachine && (
<>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Roles}
className={styles.tabContainer}
>
<MachineToMachineApplicationRoles application={data} />
</TabWrapper>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Logs}
className={styles.tabContainer}
>
<MachineLogs applicationId={data.id} />
</TabWrapper>
</>
)}
{isDevFeaturesEnabled && data.isThirdParty && (
<>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Permissions}
className={styles.tabContainer}
>
<Permissions application={data} />
</TabWrapper>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Branding}
className={styles.tabContainer}
>
<Branding application={data} />
</TabWrapper>
</>
)}
</>
<ApplicationDetailsContent
data={data}
oidcConfig={oidcConfig}
onApplicationUpdated={mutate}
/>
)}
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} onConfirm={reset} />
</DetailsPage>
);
}

View file

@ -1,10 +1,20 @@
import { type ApplicationResponse } from '@logto/schemas';
import {
customClientMetadataDefault,
type ApplicationResponse,
type Application,
} from '@logto/schemas';
export type ApplicationForm = Pick<
ApplicationResponse,
'name' | 'description' | 'oidcClientMetadata' | 'customClientMetadata' | 'isAdmin'
>;
const mapToUriFormatArrays = (value?: string[]) =>
value?.filter(Boolean).map((uri) => decodeURIComponent(uri)) ?? [];
const mapToUriOriginFormatArrays = (value?: string[]) =>
value?.filter(Boolean).map((uri) => decodeURIComponent(uri.replace(/\/*$/, ''))) ?? [];
export const applicationFormDataParser = {
fromResponse: (data: ApplicationResponse): ApplicationForm => {
const { name, description, oidcClientMetadata, customClientMetadata, isAdmin } = data;
@ -13,8 +23,29 @@ export const applicationFormDataParser = {
name,
description,
oidcClientMetadata,
customClientMetadata,
customClientMetadata: {
...customClientMetadataDefault,
...customClientMetadata,
},
isAdmin,
};
},
toUpdateApplicationData: (formData: ApplicationForm): Partial<Application> => {
return {
...formData,
oidcClientMetadata: {
...formData.oidcClientMetadata,
redirectUris: mapToUriFormatArrays(formData.oidcClientMetadata.redirectUris),
postLogoutRedirectUris: mapToUriFormatArrays(
formData.oidcClientMetadata.postLogoutRedirectUris
),
},
customClientMetadata: {
...formData.customClientMetadata,
corsAllowedOrigins: mapToUriOriginFormatArrays(
formData.customClientMetadata.corsAllowedOrigins
),
},
};
},
};