mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor(console): add dedicated routes to AC guides (#3358)
This commit is contained in:
parent
b1b5200876
commit
d2769823da
10 changed files with 362 additions and 334 deletions
|
@ -55,6 +55,7 @@ const ConsoleContent = () => {
|
|||
<Route path="applications">
|
||||
<Route index element={<Applications />} />
|
||||
<Route path="create" element={<Applications />} />
|
||||
<Route path=":id/guide" element={<ApplicationDetails />} />
|
||||
<Route path=":id" element={<ApplicationDetails />} />
|
||||
</Route>
|
||||
<Route path="api-resources">
|
||||
|
@ -77,6 +78,7 @@ const ConsoleContent = () => {
|
|||
<Route index element={<Navigate replace to={ConnectorsTabs.Passwordless} />} />
|
||||
<Route path=":tab" element={<Connectors />} />
|
||||
<Route path=":tab/create/:createType" element={<Connectors />} />
|
||||
<Route path=":tab/guide/:factoryId" element={<Connectors />} />
|
||||
<Route path=":tab/:connectorId" element={<ConnectorDetails />} />
|
||||
</Route>
|
||||
<Route path="users">
|
||||
|
|
|
@ -4,7 +4,7 @@ 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 { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import Back from '@/assets/images/back.svg';
|
||||
|
@ -42,6 +42,8 @@ const mapToUriOriginFormatArrays = (value?: string[]) =>
|
|||
|
||||
const ApplicationDetails = () => {
|
||||
const { id } = useParams();
|
||||
const { pathname } = useLocation();
|
||||
const isGuideView = !!id && pathname === `/applications/${id}/guide`;
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { data, error, mutate } = useSWR<ApplicationResponse, RequestError>(
|
||||
id && `api/applications/${id}`
|
||||
|
@ -224,6 +226,14 @@ const ApplicationDetails = () => {
|
|||
</>
|
||||
)}
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
|
||||
{isGuideView && (
|
||||
<Guide
|
||||
app={data}
|
||||
onClose={(id) => {
|
||||
navigate(`/applications/${id}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,27 +3,24 @@ import { ApplicationType } from '@logto/schemas';
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from 'react-modal';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useConfigs from '@/hooks/use-configs';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { applicationTypeI18nKey } from '@/types/applications';
|
||||
|
||||
import Guide from '../Guide';
|
||||
import TypeDescription from '../TypeDescription';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const defaultAppName = 'My App';
|
||||
|
||||
const ApplicationsPlaceholder = () => {
|
||||
type Props = {
|
||||
onCreate: (createdApp: Application) => void;
|
||||
};
|
||||
|
||||
const ApplicationsPlaceholder = ({ onCreate }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const navigate = useNavigate();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createdApplication, setCreatedApplication] = useState<Application>();
|
||||
const isGetStartedModalOpen = Boolean(createdApplication);
|
||||
const api = useApi();
|
||||
const { updateConfigs } = useConfigs();
|
||||
|
||||
|
@ -41,28 +38,18 @@ const ApplicationsPlaceholder = () => {
|
|||
try {
|
||||
const createdApp = await api.post('api/applications', { json: payload }).json<Application>();
|
||||
|
||||
setCreatedApplication(createdApp);
|
||||
|
||||
void updateConfigs({
|
||||
applicationCreated: true,
|
||||
...conditional(
|
||||
createdApp.type === ApplicationType.MachineToMachine && { m2mApplicationCreated: true }
|
||||
),
|
||||
});
|
||||
onCreate(createdApp);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeGuideModal = () => {
|
||||
if (!createdApplication) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(`/applications/${createdApplication.id}`);
|
||||
setCreatedApplication(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.placeholder}>
|
||||
<div className={styles.title}>{t('applications.placeholder_title')}</div>
|
||||
|
@ -81,23 +68,13 @@ const ApplicationsPlaceholder = () => {
|
|||
className={styles.createButton}
|
||||
disabled={isCreating}
|
||||
title="general.create"
|
||||
onClick={async () => {
|
||||
await handleCreate(type);
|
||||
onClick={() => {
|
||||
void handleCreate(type);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{createdApplication && (
|
||||
<Modal
|
||||
shouldCloseOnEsc
|
||||
isOpen={isGetStartedModalOpen}
|
||||
className={modalStyles.fullScreen}
|
||||
onRequestClose={closeGuideModal}
|
||||
>
|
||||
<Guide app={createdApplication} onClose={closeGuideModal} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import type { Application } from '@logto/schemas';
|
||||
import { ApplicationType } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { useState } from 'react';
|
||||
import { useController, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from 'react-modal';
|
||||
|
@ -16,7 +15,6 @@ import useConfigs from '@/hooks/use-configs';
|
|||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { applicationTypeI18nKey } from '@/types/applications';
|
||||
|
||||
import Guide from '../Guide';
|
||||
import TypeDescription from '../TypeDescription';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -27,13 +25,12 @@ type FormData = {
|
|||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose?: (createdApp?: Application) => void;
|
||||
};
|
||||
|
||||
const CreateForm = ({ onClose }: Props) => {
|
||||
const CreateForm = ({ isOpen, onClose }: Props) => {
|
||||
const { updateConfigs } = useConfigs();
|
||||
const [createdApp, setCreatedApp] = useState<Application>();
|
||||
const [isGetStartedModalOpen, setIsGetStartedModalOpen] = useState(false);
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
|
@ -46,10 +43,9 @@ const CreateForm = ({ onClose }: Props) => {
|
|||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const api = useApi();
|
||||
|
||||
const closeModal = () => {
|
||||
setIsGetStartedModalOpen(false);
|
||||
onClose?.(createdApp);
|
||||
};
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
if (isSubmitting) {
|
||||
|
@ -57,83 +53,82 @@ const CreateForm = ({ onClose }: Props) => {
|
|||
}
|
||||
|
||||
const createdApp = await api.post('api/applications', { json: data }).json<Application>();
|
||||
setCreatedApp(createdApp);
|
||||
setIsGetStartedModalOpen(true);
|
||||
void updateConfigs({
|
||||
applicationCreated: true,
|
||||
...conditional(
|
||||
createdApp.type === ApplicationType.MachineToMachine && { m2mApplicationCreated: true }
|
||||
),
|
||||
});
|
||||
onClose?.(createdApp);
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalLayout
|
||||
title="applications.create"
|
||||
subtitle="applications.subtitle"
|
||||
size="large"
|
||||
footer={
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
htmlType="submit"
|
||||
title="applications.create"
|
||||
size="large"
|
||||
type="primary"
|
||||
onClick={onSubmit}
|
||||
/>
|
||||
}
|
||||
onClose={onClose}
|
||||
<Modal
|
||||
shouldCloseOnEsc
|
||||
isOpen={isOpen}
|
||||
className={modalStyles.content}
|
||||
overlayClassName={modalStyles.overlay}
|
||||
onRequestClose={() => {
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
<form>
|
||||
<FormField title="applications.select_application_type">
|
||||
<RadioGroup
|
||||
ref={ref}
|
||||
className={styles.radioGroup}
|
||||
name={name}
|
||||
value={value}
|
||||
type="card"
|
||||
onChange={onChange}
|
||||
>
|
||||
{Object.values(ApplicationType).map((value) => (
|
||||
<Radio key={value} value={value}>
|
||||
<TypeDescription
|
||||
type={value}
|
||||
title={t(`${applicationTypeI18nKey[value]}.title`)}
|
||||
subtitle={t(`${applicationTypeI18nKey[value]}.subtitle`)}
|
||||
description={t(`${applicationTypeI18nKey[value]}.description`)}
|
||||
/>
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{errors.type?.type === 'required' && (
|
||||
<div className={styles.error}>{t('applications.no_application_type_selected')}</div>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField isRequired title="applications.application_name">
|
||||
<TextInput
|
||||
{...register('name', { required: true })}
|
||||
placeholder={t('applications.application_name_placeholder')}
|
||||
hasError={Boolean(errors.name)}
|
||||
<ModalLayout
|
||||
title="applications.create"
|
||||
subtitle="applications.subtitle"
|
||||
size="large"
|
||||
footer={
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
htmlType="submit"
|
||||
title="applications.create"
|
||||
size="large"
|
||||
type="primary"
|
||||
onClick={onSubmit}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="applications.application_description">
|
||||
<TextInput
|
||||
{...register('description')}
|
||||
placeholder={t('applications.application_description_placeholder')}
|
||||
/>
|
||||
</FormField>
|
||||
</form>
|
||||
{createdApp && (
|
||||
<Modal
|
||||
shouldCloseOnEsc
|
||||
isOpen={isGetStartedModalOpen}
|
||||
className={modalStyles.fullScreen}
|
||||
onRequestClose={closeModal}
|
||||
>
|
||||
<Guide app={createdApp} onClose={closeModal} />
|
||||
</Modal>
|
||||
)}
|
||||
</ModalLayout>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<form>
|
||||
<FormField title="applications.select_application_type">
|
||||
<RadioGroup
|
||||
ref={ref}
|
||||
className={styles.radioGroup}
|
||||
name={name}
|
||||
value={value}
|
||||
type="card"
|
||||
onChange={onChange}
|
||||
>
|
||||
{Object.values(ApplicationType).map((value) => (
|
||||
<Radio key={value} value={value}>
|
||||
<TypeDescription
|
||||
type={value}
|
||||
title={t(`${applicationTypeI18nKey[value]}.title`)}
|
||||
subtitle={t(`${applicationTypeI18nKey[value]}.subtitle`)}
|
||||
description={t(`${applicationTypeI18nKey[value]}.description`)}
|
||||
/>
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{errors.type?.type === 'required' && (
|
||||
<div className={styles.error}>{t('applications.no_application_type_selected')}</div>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField isRequired title="applications.application_name">
|
||||
<TextInput
|
||||
{...register('name', { required: true })}
|
||||
placeholder={t('applications.application_name_placeholder')}
|
||||
hasError={Boolean(errors.name)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="applications.application_description">
|
||||
<TextInput
|
||||
{...register('description')}
|
||||
placeholder={t('applications.application_description_placeholder')}
|
||||
/>
|
||||
</FormField>
|
||||
</form>
|
||||
</ModalLayout>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -4,12 +4,14 @@ import type { Optional } from '@silverhand/essentials';
|
|||
import i18next from 'i18next';
|
||||
import type { MDXProps } from 'mdx/types';
|
||||
import type { LazyExoticComponent } from 'react';
|
||||
import { useContext, cloneElement, lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { useEffect, useContext, cloneElement, lazy, Suspense, useState } from 'react';
|
||||
import Modal from 'react-modal';
|
||||
|
||||
import CodeEditor from '@/components/CodeEditor';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import { AppEndpointsContext } from '@/contexts/AppEndpointsProvider';
|
||||
import DetailsSummary from '@/mdx-components/DetailsSummary';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import type { SupportedSdk } from '@/types/applications';
|
||||
import { applicationTypeAndSdkTypeMappings } from '@/types/applications';
|
||||
|
||||
|
@ -19,9 +21,9 @@ import StepsSkeleton from '../StepsSkeleton';
|
|||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
app: Application;
|
||||
app?: Application;
|
||||
isCompact?: boolean;
|
||||
onClose: () => void;
|
||||
onClose: (id: string) => void;
|
||||
};
|
||||
|
||||
const Guides: Record<string, LazyExoticComponent<(props: MDXProps) => JSX.Element>> = {
|
||||
|
@ -50,83 +52,93 @@ const Guides: Record<string, LazyExoticComponent<(props: MDXProps) => JSX.Elemen
|
|||
};
|
||||
|
||||
const Guide = ({ app, isCompact, onClose }: Props) => {
|
||||
const { id: appId, secret: appSecret, name: appName, type: appType, oidcClientMetadata } = app;
|
||||
const sdks = applicationTypeAndSdkTypeMappings[appType];
|
||||
const [selectedSdk, setSelectedSdk] = useState<Optional<SupportedSdk>>(sdks[0]);
|
||||
const sdks = app && applicationTypeAndSdkTypeMappings[app.type];
|
||||
const [selectedSdk, setSelectedSdk] = useState<Optional<SupportedSdk>>();
|
||||
const [activeStepIndex, setActiveStepIndex] = useState(-1);
|
||||
const { userEndpoint } = useContext(AppEndpointsContext);
|
||||
|
||||
// Directly close guide if no SDK available
|
||||
useEffect(() => {
|
||||
if (!selectedSdk) {
|
||||
onClose();
|
||||
if (sdks?.length) {
|
||||
setSelectedSdk(sdks[0]);
|
||||
}
|
||||
}, [onClose, selectedSdk]);
|
||||
}, [sdks]);
|
||||
|
||||
if (!selectedSdk) {
|
||||
if (!app || !sdks || !selectedSdk) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id: appId, secret: appSecret, name: appName, oidcClientMetadata } = app;
|
||||
const locale = i18next.language;
|
||||
const guideI18nKey = `${selectedSdk}_${locale}`.toLowerCase();
|
||||
const GuideComponent = Guides[guideI18nKey] ?? Guides[selectedSdk.toLowerCase()];
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<GuideHeader
|
||||
appName={appName}
|
||||
selectedSdk={selectedSdk}
|
||||
isCompact={isCompact}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
{cloneElement(<SdkSelector sdks={sdks} selectedSdk={selectedSdk} />, {
|
||||
className: styles.banner,
|
||||
isCompact,
|
||||
onChange: setSelectedSdk,
|
||||
onToggle: () => {
|
||||
setActiveStepIndex(0);
|
||||
},
|
||||
})}
|
||||
<MDXProvider
|
||||
components={{
|
||||
code: ({ className, children }) => {
|
||||
const [, language] = /language-(\w+)/.exec(className ?? '') ?? [];
|
||||
const closeModal = () => {
|
||||
onClose(appId);
|
||||
};
|
||||
|
||||
return language ? (
|
||||
<CodeEditor isReadonly language={language} value={String(children)} />
|
||||
) : (
|
||||
<code>{String(children)}</code>
|
||||
);
|
||||
return (
|
||||
<Modal
|
||||
shouldCloseOnEsc
|
||||
isOpen={Boolean(app)}
|
||||
className={modalStyles.fullScreen}
|
||||
onRequestClose={closeModal}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<GuideHeader
|
||||
appName={appName}
|
||||
selectedSdk={selectedSdk}
|
||||
isCompact={isCompact}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
{cloneElement(<SdkSelector sdks={sdks} selectedSdk={selectedSdk} />, {
|
||||
className: styles.banner,
|
||||
isCompact,
|
||||
onChange: setSelectedSdk,
|
||||
onToggle: () => {
|
||||
setActiveStepIndex(0);
|
||||
},
|
||||
a: ({ children, ...props }) => (
|
||||
<TextLink {...props} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</TextLink>
|
||||
),
|
||||
details: DetailsSummary,
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<StepsSkeleton />}>
|
||||
{GuideComponent && (
|
||||
<GuideComponent
|
||||
appId={appId}
|
||||
appSecret={appSecret}
|
||||
endpoint={userEndpoint}
|
||||
redirectUris={oidcClientMetadata.redirectUris}
|
||||
postLogoutRedirectUris={oidcClientMetadata.postLogoutRedirectUris}
|
||||
activeStepIndex={activeStepIndex}
|
||||
isCompact={isCompact}
|
||||
onNext={(nextIndex: number) => {
|
||||
setActiveStepIndex(nextIndex);
|
||||
}}
|
||||
onComplete={onClose}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</MDXProvider>
|
||||
})}
|
||||
<MDXProvider
|
||||
components={{
|
||||
code: ({ className, children }) => {
|
||||
const [, language] = /language-(\w+)/.exec(className ?? '') ?? [];
|
||||
|
||||
return language ? (
|
||||
<CodeEditor isReadonly language={language} value={String(children)} />
|
||||
) : (
|
||||
<code>{String(children)}</code>
|
||||
);
|
||||
},
|
||||
a: ({ children, ...props }) => (
|
||||
<TextLink {...props} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</TextLink>
|
||||
),
|
||||
details: DetailsSummary,
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<StepsSkeleton />}>
|
||||
{GuideComponent && (
|
||||
<GuideComponent
|
||||
appId={appId}
|
||||
appSecret={appSecret}
|
||||
endpoint={userEndpoint}
|
||||
redirectUris={oidcClientMetadata.redirectUris}
|
||||
postLogoutRedirectUris={oidcClientMetadata.postLogoutRedirectUris}
|
||||
activeStepIndex={activeStepIndex}
|
||||
isCompact={isCompact}
|
||||
onNext={(nextIndex: number) => {
|
||||
setActiveStepIndex(nextIndex);
|
||||
}}
|
||||
onComplete={closeModal}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</MDXProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { Application } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from 'react-modal';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
|
@ -15,7 +15,6 @@ import Table from '@/components/Table';
|
|||
import { defaultPageSize } from '@/consts';
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import * as resourcesStyles from '@/scss/resources.module.scss';
|
||||
import { applicationTypeI18nKey } from '@/types/applications';
|
||||
import { buildUrl } from '@/utils/url';
|
||||
|
@ -28,11 +27,12 @@ const pageSize = defaultPageSize;
|
|||
const applicationsPathname = '/applications';
|
||||
const createApplicationPathname = `${applicationsPathname}/create`;
|
||||
const buildDetailsPathname = (id: string) => `${applicationsPathname}/${id}`;
|
||||
const buildGuidePathname = (id: string) => `${buildDetailsPathname(id)}/guide`;
|
||||
|
||||
const Applications = () => {
|
||||
const navigate = useNavigate();
|
||||
const { pathname, search } = useLocation();
|
||||
const isCreateNew = pathname === createApplicationPathname;
|
||||
const isShowingCreationForm = pathname === createApplicationPathname;
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const [{ page }, updateSearchParameters] = useSearchParametersWatcher({
|
||||
|
@ -49,6 +49,13 @@ const Applications = () => {
|
|||
const isLoading = !data && !error;
|
||||
const [applications, totalCount] = data ?? [];
|
||||
|
||||
const mutateApplicationList = useCallback(
|
||||
async (newApp: Application) => {
|
||||
await mutate([[newApp, ...(applications ?? [])], (totalCount ?? 0) + 1]);
|
||||
},
|
||||
[applications, totalCount, mutate]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={resourcesStyles.container}>
|
||||
<div className={resourcesStyles.headline}>
|
||||
|
@ -65,32 +72,6 @@ const Applications = () => {
|
|||
});
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
shouldCloseOnEsc
|
||||
isOpen={isCreateNew}
|
||||
className={modalStyles.content}
|
||||
overlayClassName={modalStyles.overlay}
|
||||
onRequestClose={() => {
|
||||
navigate({
|
||||
pathname: applicationsPathname,
|
||||
search,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CreateForm
|
||||
onClose={(createdApp) => {
|
||||
if (createdApp) {
|
||||
navigate(buildDetailsPathname(createdApp.id), { replace: true });
|
||||
|
||||
return;
|
||||
}
|
||||
navigate({
|
||||
pathname: applicationsPathname,
|
||||
search,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
<Table
|
||||
className={resourcesStyles.table}
|
||||
|
@ -119,7 +100,14 @@ const Applications = () => {
|
|||
render: ({ id }) => <CopyToClipboard value={id} variant="text" />,
|
||||
},
|
||||
]}
|
||||
placeholder={<ApplicationsPlaceholder />}
|
||||
placeholder={
|
||||
<ApplicationsPlaceholder
|
||||
onCreate={async (newApp) => {
|
||||
await mutateApplicationList(newApp);
|
||||
navigate(buildGuidePathname(newApp.id), { replace: true });
|
||||
}}
|
||||
/>
|
||||
}
|
||||
rowClickHandler={({ id }) => {
|
||||
navigate(buildDetailsPathname(id));
|
||||
}}
|
||||
|
@ -134,6 +122,20 @@ const Applications = () => {
|
|||
updateSearchParameters({ page });
|
||||
}}
|
||||
/>
|
||||
<CreateForm
|
||||
isOpen={isShowingCreationForm}
|
||||
onClose={async (newApp) => {
|
||||
if (newApp) {
|
||||
navigate(buildGuidePathname(newApp.id), { replace: true });
|
||||
|
||||
return;
|
||||
}
|
||||
navigate({
|
||||
pathname: applicationsPathname,
|
||||
search,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -20,7 +20,7 @@ type Props = {
|
|||
formItems?: ConnectorConfigFormItem[];
|
||||
className?: string;
|
||||
connectorId: string;
|
||||
connectorType: ConnectorType;
|
||||
connectorType?: ConnectorType;
|
||||
};
|
||||
|
||||
const ConfigForm = ({
|
||||
|
|
|
@ -14,7 +14,6 @@ import type { RequestError } from '@/hooks/use-api';
|
|||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
import { getConnectorGroups } from '../../utils';
|
||||
import Guide from '../Guide';
|
||||
import PlatformSelector from './PlatformSelector';
|
||||
import * as styles from './index.module.scss';
|
||||
import { getConnectorOrder } from './utils';
|
||||
|
@ -37,7 +36,6 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
|
|||
const isLoading = !factories && !existingConnectors && !connectorsError && !factoriesError;
|
||||
const [activeGroupId, setActiveGroupId] = useState<string>();
|
||||
const [activeFactoryId, setActiveFactoryId] = useState<string>();
|
||||
const [isGetStartedModalOpen, setIsGetStartedModalOpen] = useState(false);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!factories || !existingConnectors) {
|
||||
|
@ -73,11 +71,6 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
|
|||
[activeGroupId, groups]
|
||||
);
|
||||
|
||||
const activeFactory = useMemo(
|
||||
() => factories?.find(({ id }) => id === activeFactoryId),
|
||||
[activeFactoryId, factories]
|
||||
);
|
||||
|
||||
const cardTitle = useMemo(() => {
|
||||
if (type === ConnectorType.Email) {
|
||||
return 'connectors.setup_title.email';
|
||||
|
@ -90,6 +83,22 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
|
|||
return 'connectors.setup_title.social';
|
||||
}, [type]);
|
||||
|
||||
const modalSize = useMemo(() => {
|
||||
if (groups.length <= 2) {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
if (groups.length === 3) {
|
||||
return 'large';
|
||||
}
|
||||
|
||||
return 'xlarge';
|
||||
}, [groups.length]);
|
||||
|
||||
if (!isFormOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleGroupChange = (groupId: string) => {
|
||||
setActiveGroupId(groupId);
|
||||
|
||||
|
@ -104,25 +113,6 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
|
|||
setActiveFactoryId(firstAvailableConnector?.id);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsGetStartedModalOpen(false);
|
||||
onClose?.(activeFactoryId);
|
||||
setActiveGroupId(undefined);
|
||||
setActiveFactoryId(undefined);
|
||||
};
|
||||
|
||||
const modalSize = useMemo(() => {
|
||||
if (groups.length <= 2) {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
if (groups.length === 3) {
|
||||
return 'large';
|
||||
}
|
||||
|
||||
return 'xlarge';
|
||||
}, [groups]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
shouldCloseOnEsc
|
||||
|
@ -141,7 +131,7 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
|
|||
type="primary"
|
||||
disabled={!activeFactoryId}
|
||||
onClick={() => {
|
||||
setIsGetStartedModalOpen(true);
|
||||
onClose?.(activeFactoryId);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
@ -181,16 +171,6 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
|
|||
onConnectorIdChange={setActiveFactoryId}
|
||||
/>
|
||||
)}
|
||||
{activeFactory && (
|
||||
<Modal
|
||||
shouldCloseOnEsc
|
||||
isOpen={isGetStartedModalOpen}
|
||||
className={modalStyles.fullScreen}
|
||||
onRequestClose={closeModal}
|
||||
>
|
||||
<Guide connector={activeFactory} onClose={closeModal} />
|
||||
</Modal>
|
||||
)}
|
||||
</ModalLayout>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
@ -5,10 +5,11 @@ import { ConnectorType } from '@logto/schemas';
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import i18next from 'i18next';
|
||||
import { HTTPError } from 'ky';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from 'react-modal';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Close from '@/assets/images/close.svg';
|
||||
|
@ -21,6 +22,7 @@ import { ConnectorsTabs } from '@/consts/page-tabs';
|
|||
import useApi from '@/hooks/use-api';
|
||||
import useConfigs from '@/hooks/use-configs';
|
||||
import SenderTester from '@/pages/ConnectorDetails/components/SenderTester';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
import type { ConnectorFormType } from '../../types';
|
||||
import { SyncProfileMode } from '../../types';
|
||||
|
@ -34,30 +36,22 @@ import * as styles from './index.module.scss';
|
|||
const targetErrorCode = 'connector.multiple_target_with_same_platform';
|
||||
|
||||
type Props = {
|
||||
connector: ConnectorFactoryResponse;
|
||||
onClose: () => void;
|
||||
connector?: ConnectorFactoryResponse;
|
||||
onClose: (id?: string) => void;
|
||||
};
|
||||
|
||||
const Guide = ({ connector, onClose }: Props) => {
|
||||
const api = useApi({ hideErrorToast: true });
|
||||
const navigate = useNavigate();
|
||||
const [callbackConnectorId, setCallbackConnectorId] = useState<string>(generateStandardId());
|
||||
const callbackConnectorId = useRef(generateStandardId());
|
||||
const { updateConfigs } = useConfigs();
|
||||
const parseJsonConfig = useConfigParser();
|
||||
const [conflictConnectorName, setConflictConnectorName] = useState<Record<string, string>>();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const {
|
||||
id: connectorId,
|
||||
type: connectorType,
|
||||
name,
|
||||
readme,
|
||||
formItems,
|
||||
target,
|
||||
isStandard,
|
||||
} = connector;
|
||||
const { title, content } = splitMarkdownByTitle(readme);
|
||||
const { type: connectorType, formItems, target, isStandard } = connector ?? {};
|
||||
|
||||
const { language } = i18next;
|
||||
const connectorName = conditional(isLanguageTag(language) && name[language]) ?? name.en;
|
||||
|
||||
const isSocialConnector =
|
||||
connectorType !== ConnectorType.Sms && connectorType !== ConnectorType.Email;
|
||||
const methods = useForm<ConnectorFormType>({
|
||||
|
@ -76,11 +70,19 @@ const Guide = ({ connector, onClose }: Props) => {
|
|||
} = methods;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSocialConnector && !isStandard) {
|
||||
if (isSocialConnector && !isStandard && target) {
|
||||
setValue('target', target);
|
||||
}
|
||||
}, [isSocialConnector, target, isStandard, setValue]);
|
||||
|
||||
if (!connector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id: connectorId, name, readme, configTemplate } = connector;
|
||||
const { title, content } = splitMarkdownByTitle(readme);
|
||||
const connectorName = conditional(isLanguageTag(language) && name[language]) ?? name.en;
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
|
@ -89,14 +91,13 @@ const Guide = ({ connector, onClose }: Props) => {
|
|||
// Recover error state
|
||||
setConflictConnectorName(undefined);
|
||||
|
||||
const { formItems, isStandard, id: connectorId, type } = connector;
|
||||
const config = formItems ? parseFormConfig(data, formItems) : parseJsonConfig(data.config);
|
||||
const { syncProfile, name, logo, logoDark, target } = data;
|
||||
|
||||
const basePayload = {
|
||||
config,
|
||||
connectorId,
|
||||
id: conditional(type === ConnectorType.Social && callbackConnectorId),
|
||||
id: conditional(connectorType === ConnectorType.Social && callbackConnectorId.current),
|
||||
metadata: conditional(
|
||||
isStandard && {
|
||||
logo,
|
||||
|
@ -149,77 +150,91 @@ const Guide = ({ connector, onClose }: Props) => {
|
|||
});
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<IconButton size="large" onClick={onClose}>
|
||||
<Close className={styles.closeIcon} />
|
||||
</IconButton>
|
||||
<div className={styles.separator} />
|
||||
<CardTitle
|
||||
size="small"
|
||||
title={<DangerousRaw>{connectorName}</DangerousRaw>}
|
||||
subtitle="connectors.guide.subtitle"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.readme}>
|
||||
<div className={styles.readmeTitle}>README: {title}</div>
|
||||
<Markdown className={styles.readmeContent}>{content}</Markdown>
|
||||
<Modal
|
||||
shouldCloseOnEsc
|
||||
isOpen={Boolean(connector)}
|
||||
className={modalStyles.fullScreen}
|
||||
onRequestClose={() => {
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<IconButton
|
||||
size="large"
|
||||
onClick={() => {
|
||||
onClose(callbackConnectorId.current);
|
||||
}}
|
||||
>
|
||||
<Close className={styles.closeIcon} />
|
||||
</IconButton>
|
||||
<div className={styles.separator} />
|
||||
<CardTitle
|
||||
size="small"
|
||||
title={<DangerousRaw>{connectorName}</DangerousRaw>}
|
||||
subtitle="connectors.guide.subtitle"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.setup}>
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={onSubmit}>
|
||||
{isSocialConnector && (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.readme}>
|
||||
<div className={styles.readmeTitle}>README: {title}</div>
|
||||
<Markdown className={styles.readmeContent}>{content}</Markdown>
|
||||
</div>
|
||||
<div className={styles.setup}>
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={onSubmit}>
|
||||
{isSocialConnector && (
|
||||
<div className={styles.block}>
|
||||
<div className={styles.blockTitle}>
|
||||
<div className={styles.number}>1</div>
|
||||
<div>{t('connectors.guide.general_setting')}</div>
|
||||
</div>
|
||||
<BasicForm
|
||||
isAllowEditTarget={isStandard}
|
||||
isStandard={isStandard}
|
||||
conflictConnectorName={conflictConnectorName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.block}>
|
||||
<div className={styles.blockTitle}>
|
||||
<div className={styles.number}>1</div>
|
||||
<div>{t('connectors.guide.general_setting')}</div>
|
||||
<div className={styles.number}>{isSocialConnector ? 2 : 1}</div>
|
||||
<div>{t('connectors.guide.parameter_configuration')}</div>
|
||||
</div>
|
||||
<BasicForm
|
||||
isAllowEditTarget={connector.isStandard}
|
||||
isStandard={connector.isStandard}
|
||||
conflictConnectorName={conflictConnectorName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.block}>
|
||||
<div className={styles.blockTitle}>
|
||||
<div className={styles.number}>{isSocialConnector ? 2 : 1}</div>
|
||||
<div>{t('connectors.guide.parameter_configuration')}</div>
|
||||
</div>
|
||||
<ConfigForm
|
||||
connectorId={callbackConnectorId}
|
||||
configTemplate={connector.configTemplate}
|
||||
connectorType={connectorType}
|
||||
formItems={connector.formItems}
|
||||
/>
|
||||
</div>
|
||||
{!isSocialConnector && (
|
||||
<div className={styles.block}>
|
||||
<div className={styles.blockTitle}>
|
||||
<div className={styles.number}>2</div>
|
||||
<div>{t('connectors.guide.test_connection')}</div>
|
||||
</div>
|
||||
<SenderTester
|
||||
connectorId={connectorId}
|
||||
<ConfigForm
|
||||
connectorId={callbackConnectorId.current}
|
||||
configTemplate={configTemplate}
|
||||
connectorType={connectorType}
|
||||
config={watch('config')}
|
||||
formItems={formItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
title="connectors.save_and_done"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
{!isSocialConnector && (
|
||||
<div className={styles.block}>
|
||||
<div className={styles.blockTitle}>
|
||||
<div className={styles.number}>2</div>
|
||||
<div>{t('connectors.guide.test_connection')}</div>
|
||||
</div>
|
||||
<SenderTester
|
||||
connectorId={connectorId}
|
||||
connectorType={connectorType}
|
||||
config={watch('config')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
title="connectors.save_and_done"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import type { ConnectorFactoryResponse } from '@logto/schemas';
|
||||
import { ConnectorType } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import Plus from '@/assets/images/plus.svg';
|
||||
import SocialConnectorEmptyDark from '@/assets/images/social-connector-empty-dark.svg';
|
||||
|
@ -15,6 +17,7 @@ import Table from '@/components/Table';
|
|||
import TablePlaceholder from '@/components/Table/TablePlaceholder';
|
||||
import { defaultEmailConnectorGroup, defaultSmsConnectorGroup } from '@/consts';
|
||||
import { ConnectorsTabs } from '@/consts/page-tabs';
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import useConnectorGroups from '@/hooks/use-connector-groups';
|
||||
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
||||
import * as resourcesStyles from '@/scss/resources.module.scss';
|
||||
|
@ -24,6 +27,7 @@ import ConnectorStatus from './components/ConnectorStatus';
|
|||
import ConnectorStatusField from './components/ConnectorStatusField';
|
||||
import ConnectorTypeColumn from './components/ConnectorTypeColumn';
|
||||
import CreateForm from './components/CreateForm';
|
||||
import Guide from './components/Guide';
|
||||
import SignInExperienceSetupNotice from './components/SignInExperienceSetupNotice';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -31,10 +35,19 @@ const basePathname = '/connectors';
|
|||
const passwordlessPathname = `${basePathname}/${ConnectorsTabs.Passwordless}`;
|
||||
const socialPathname = `${basePathname}/${ConnectorsTabs.Social}`;
|
||||
|
||||
const buildCreatePathname = (connectorType: ConnectorType) => {
|
||||
const pathname = connectorType === ConnectorType.Social ? socialPathname : passwordlessPathname;
|
||||
const buildTabPathname = (connectorType: ConnectorType) =>
|
||||
connectorType === ConnectorType.Social ? socialPathname : passwordlessPathname;
|
||||
|
||||
return `${pathname}/create/${connectorType}`;
|
||||
const buildCreatePathname = (connectorType: ConnectorType) => {
|
||||
const tabPath = buildTabPathname(connectorType);
|
||||
|
||||
return `${tabPath}/create/${connectorType}`;
|
||||
};
|
||||
|
||||
const buildGuidePathname = (connectorType: ConnectorType, factoryId: string) => {
|
||||
const tabPath = buildTabPathname(connectorType);
|
||||
|
||||
return `${tabPath}/guide/${factoryId}`;
|
||||
};
|
||||
|
||||
const isConnectorType = (value: string): value is ConnectorType =>
|
||||
|
@ -44,14 +57,20 @@ const parseToConnectorType = (value?: string): ConnectorType | undefined =>
|
|||
conditional(value && isConnectorType(value) && value);
|
||||
|
||||
const Connectors = () => {
|
||||
const { tab = ConnectorsTabs.Passwordless, createType } = useParams();
|
||||
const { tab = ConnectorsTabs.Passwordless, createType, factoryId } = useParams();
|
||||
const createConnectorType = parseToConnectorType(createType);
|
||||
const navigate = useNavigate();
|
||||
const isSocial = tab === ConnectorsTabs.Social;
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { getDocumentationUrl } = useDocumentationUrl();
|
||||
|
||||
const { data, error, mutate } = useConnectorGroups();
|
||||
const isLoading = !data && !error;
|
||||
const { data: factories, error: factoriesError } = useSWR<
|
||||
ConnectorFactoryResponse[],
|
||||
RequestError
|
||||
>('api/connector-factories');
|
||||
|
||||
const isLoading = !data && !factories && !error && !factoriesError;
|
||||
|
||||
const passwordlessConnectors = useMemo(() => {
|
||||
const smsConnector =
|
||||
|
@ -70,6 +89,12 @@ const Connectors = () => {
|
|||
|
||||
const connectors = isSocial ? socialConnectors : passwordlessConnectors;
|
||||
|
||||
const connectorToShowInGuide = useMemo(() => {
|
||||
if (factories && factoryId) {
|
||||
return factories.find(({ id }) => id === factoryId);
|
||||
}
|
||||
}, [factoryId, factories]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames(resourcesStyles.container, styles.container)}>
|
||||
|
@ -159,16 +184,26 @@ const Connectors = () => {
|
|||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
</div>
|
||||
{Boolean(createConnectorType) && (
|
||||
<CreateForm
|
||||
isOpen
|
||||
type={createConnectorType}
|
||||
onClose={() => {
|
||||
navigate(`${basePathname}/${tab}`);
|
||||
void mutate();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CreateForm
|
||||
isOpen={Boolean(createConnectorType)}
|
||||
type={createConnectorType}
|
||||
onClose={async (id) => {
|
||||
await mutate();
|
||||
|
||||
if (createConnectorType && id) {
|
||||
navigate(buildGuidePathname(createConnectorType, id), { replace: true });
|
||||
|
||||
return;
|
||||
}
|
||||
navigate(`${basePathname}/${tab}`);
|
||||
}}
|
||||
/>
|
||||
<Guide
|
||||
connector={connectorToShowInGuide}
|
||||
onClose={() => {
|
||||
navigate(`${basePathname}/${tab}`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue