mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor(console): improve ux
This commit is contained in:
parent
dce56eeb3f
commit
01df8f46a2
26 changed files with 256 additions and 156 deletions
|
@ -9,7 +9,7 @@ builder.Services.AddLogtoAuthentication(options =>
|
|||
{
|
||||
options.Endpoint = "${props.endpoint}";
|
||||
options.AppId = "${props.app.id}";
|
||||
options.AppSecret = "${props.app.secret}";
|
||||
options.AppSecret = "${props.secrets[0]?.value ?? props.app.secret}";
|
||||
});
|
||||
|
||||
app.UseAuthentication();`}
|
||||
|
|
|
@ -31,7 +31,7 @@ Prepare configuration for the Logto client:
|
|||
const config: LogtoExpressConfig = {
|
||||
endpoint: '${props.endpoint}',
|
||||
appId: '${props.app.id}',
|
||||
appSecret: '${props.app.secret}',
|
||||
appSecret: '${props.secrets[0]?.value ?? props.app.secret}',
|
||||
baseUrl: 'http://localhost:3000', // Change to your own base URL
|
||||
};
|
||||
`}
|
||||
|
|
|
@ -145,7 +145,7 @@ First, create a Logto config:
|
|||
logtoConfig := &client.LogtoConfig{
|
||||
Endpoint: "${props.endpoint}",
|
||||
AppId: "${props.app.id}",
|
||||
AppSecret: "${props.app.secret}",
|
||||
AppSecret: "${props.secrets[0]?.value ?? props.app.secret}",
|
||||
}
|
||||
|
||||
// ...
|
||||
|
|
|
@ -63,7 +63,7 @@ Add the following configuration to your `application.properties` file:
|
|||
<Code className="language-properties" title="application.properties">
|
||||
{`spring.security.oauth2.client.registration.logto.client-name=logto
|
||||
spring.security.oauth2.client.registration.logto.client-id=${props.app.id}
|
||||
spring.security.oauth2.client.registration.logto.client-secret=${props.app.secret}
|
||||
spring.security.oauth2.client.registration.logto.client-secret=${props.secrets[0]?.value ?? props.app.secret}
|
||||
spring.security.oauth2.client.registration.logto.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
|
||||
spring.security.oauth2.client.registration.logto.authorization-grant-type=authorization_code
|
||||
spring.security.oauth2.client.registration.logto.scope=openid,profile,email,offline_access
|
||||
|
|
|
@ -26,7 +26,7 @@ Prepare configuration for the Logto client:
|
|||
{`export const logtoConfig = {
|
||||
endpoint: '${props.endpoint}',
|
||||
appId: '${props.app.id}',
|
||||
appSecret: '${props.app.secret}',
|
||||
appSecret: '${props.secrets[0]?.value ?? props.app.secret}',
|
||||
baseUrl: 'http://localhost:3000', // Change to your own base URL
|
||||
cookieSecret: '${generateStandardSecret()}', // Auto-generated 32 digit secret
|
||||
cookieSecure: process.env.NODE_ENV === 'production',
|
||||
|
|
|
@ -45,7 +45,7 @@ const handler = NextAuth({
|
|||
wellKnown: '${props.endpoint}oidc/.well-known/openid-configuration',
|
||||
authorization: { params: { scope: 'openid offline_access profile email' } },
|
||||
clientId: '${props.app.id}'',
|
||||
clientSecret: '${props.app.secret}',
|
||||
clientSecret: '${props.secrets[0]?.value ?? props.app.secret}',
|
||||
client: {
|
||||
id_token_signed_response_alg: 'ES384',
|
||||
},
|
||||
|
|
|
@ -28,7 +28,7 @@ Import and initialize LogtoClient:
|
|||
export const logtoClient = new LogtoClient({
|
||||
endpoint: '${props.endpoint}',
|
||||
appId: '${props.app.id}',
|
||||
appSecret: '${props.app.secret}',
|
||||
appSecret: '${props.secrets[0]?.value ?? props.app.secret}',
|
||||
baseUrl: '${defaultBaseUrl}', // Change to your own base URL
|
||||
cookieSecret: '${generateStandardSecret()}', // Auto-generated 32 digit secret
|
||||
cookieSecure: process.env.NODE_ENV === 'production',
|
||||
|
|
|
@ -35,7 +35,7 @@ In your Nuxt config file, add the Logto module and configure it:
|
|||
logto: {
|
||||
endpoint: '${props.endpoint}',
|
||||
appId: '${props.app.id}',
|
||||
appSecret: '${props.app.secret}',
|
||||
appSecret: '${props.secrets[0]?.value ?? props.app.secret}',
|
||||
cookieEncryptionKey: '${cookieEncryptionKey}', // Random-generated
|
||||
},
|
||||
},
|
||||
|
@ -48,7 +48,7 @@ Since these information are sensitive, it's recommended to use environment varia
|
|||
<Code title=".env" className="language-bash">
|
||||
{`NUXT_LOGTO_ENDPOINT=${props.endpoint}
|
||||
NUXT_LOGTO_APP_ID=${props.app.id}
|
||||
NUXT_LOGTO_APP_SECRET=${props.app.secret}
|
||||
NUXT_LOGTO_APP_SECRET=${props.secrets[0]?.value ?? props.app.secret}
|
||||
NUXT_LOGTO_COOKIE_ENCRYPTION_KEY=${cookieEncryptionKey} # Random-generated
|
||||
`}
|
||||
</Code>
|
||||
|
|
|
@ -35,7 +35,7 @@ $client = new LogtoClient(
|
|||
new LogtoConfig(
|
||||
endpoint: "${props.endpoint}",
|
||||
appId: "${props.app.id}",
|
||||
appSecret: "${props.app.secret}",
|
||||
appSecret: "${props.secrets[0]?.value ?? props.app.secret}",
|
||||
),
|
||||
);`}
|
||||
</Code>
|
||||
|
|
|
@ -32,7 +32,7 @@ client = LogtoClient(
|
|||
LogtoConfig(
|
||||
endpoint="${props.endpoint}",
|
||||
appId="${props.app.id}",
|
||||
appSecret="${props.app.secret}",
|
||||
appSecret="${props.secrets[0]?.value ?? props.app.secret}",
|
||||
)
|
||||
)`}
|
||||
</Code>
|
||||
|
|
|
@ -41,7 +41,7 @@ In the file where you want to initialize the Logto client (e.g. a base controlle
|
|||
config: LogtoClient::Config.new(
|
||||
endpoint: "${props.endpoint}",
|
||||
app_id: "${props.app.id}",
|
||||
app_secret: "${props.app.secret}"
|
||||
app_secret: "${props.secrets[0]?.value ?? props.app.secret}"
|
||||
),
|
||||
navigate: ->(uri) { a_redirect_method(uri) },
|
||||
storage: LogtoClient::SessionStorage.new(the_session_object)
|
||||
|
@ -64,7 +64,7 @@ class SampleController < ApplicationController
|
|||
config: LogtoClient::Config.new(
|
||||
endpoint: "${props.endpoint}",
|
||||
app_id: "${props.app.id}",
|
||||
app_secret: "${props.app.secret}"
|
||||
app_secret: "${props.secrets[0]?.value ?? props.app.secret}"
|
||||
),
|
||||
# Allow the client to redirect to other hosts (i.e. your Logto tenant)
|
||||
navigate: ->(uri) { redirect_to(uri, allow_other_host: true) },
|
||||
|
|
|
@ -40,7 +40,7 @@ export const handle = handleLogto(
|
|||
{
|
||||
endpoint: '${props.endpoint}',
|
||||
appId: '${props.app.id}',
|
||||
appSecret: '${props.app.secret}',
|
||||
appSecret: '${props.secrets[0]?.value ?? props.app.secret}',
|
||||
},
|
||||
{ encryptionKey: '${cookieEncryptionKey}' } // Random-generated key
|
||||
);`}
|
||||
|
|
|
@ -28,36 +28,34 @@
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: text;
|
||||
gap: _.unit(2);
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: _.unit(2);
|
||||
margin-right: _.unit(1);
|
||||
flex-wrap: nowrap;
|
||||
|
||||
&.wrapContent {
|
||||
text-overflow: unset;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.copyToolTipAnchor {
|
||||
margin-left: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
||||
&.default {
|
||||
.row {
|
||||
.copyToolTipAnchor {
|
||||
margin-left: _.unit(3);
|
||||
}
|
||||
gap: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
.row {
|
||||
.copyToolTipAnchor {
|
||||
margin-left: _.unit(1);
|
||||
}
|
||||
gap: _.unit(0.5);
|
||||
|
||||
.iconButton {
|
||||
height: 20px;
|
||||
|
@ -72,4 +70,13 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,7 +60,10 @@ function CopyToClipboard(
|
|||
return value;
|
||||
}
|
||||
|
||||
return '•'.repeat(value.length);
|
||||
return Array.from({ length: Math.max(Math.floor((value.length / 5) * 3), 1) }).map(
|
||||
// eslint-disable-next-line react/no-array-index-key -- No need to persist the key
|
||||
(_, index) => <span key={index} className={styles.dot} />
|
||||
);
|
||||
}, [hasVisibilityToggle, showHiddenContent, value]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -107,7 +110,7 @@ function CopyToClipboard(
|
|||
{variant !== 'icon' && (
|
||||
<div
|
||||
className={classNames(styles.content, isWordWrapAllowed && styles.wrapContent)}
|
||||
style={valueStyle}
|
||||
style={{ width: `${value.length}ch`, ...valueStyle }}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
|
@ -124,11 +127,7 @@ function CopyToClipboard(
|
|||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
isSuccessful={copyState === 'copied'}
|
||||
anchorClassName={styles.copyToolTipAnchor}
|
||||
content={t(copyState)}
|
||||
>
|
||||
<Tooltip isSuccessful={copyState === 'copied'} content={t(copyState)}>
|
||||
<IconButton
|
||||
ref={copyIconReference}
|
||||
className={styles.iconButton}
|
||||
|
|
|
@ -36,7 +36,7 @@ function CreateSecretModal({ appId, isOpen, onClose }: Props) {
|
|||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = useForm<FormData>({ defaultValues: { name: '', expiration: String(days[0]) } });
|
||||
} = useForm<FormData>({ defaultValues: { name: '', expiration: neverExpires } });
|
||||
const onCloseHandler = useCallback(
|
||||
(created?: ApplicationSecret) => {
|
||||
reset();
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.customEndpointNotes {
|
||||
margin-top: _.unit(6);
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.fieldButton {
|
||||
margin-top: _.unit(2);
|
||||
}
|
||||
|
||||
.trailingIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
button.add {
|
||||
margin-top: _.unit(2);
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: _.unit(2);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text-secondary);
|
||||
margin: _.unit(3) 0;
|
||||
}
|
||||
|
||||
.expired {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.copyToClipboard {
|
||||
width: fit-content;
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
type ApplicationSecret,
|
||||
DomainStatus,
|
||||
type Application,
|
||||
type SnakeCaseOidcConfig,
|
||||
|
@ -15,7 +14,6 @@ import CaretDown from '@/assets/icons/caret-down.svg?react';
|
|||
import CaretUp from '@/assets/icons/caret-up.svg?react';
|
||||
import CirclePlus from '@/assets/icons/circle-plus.svg?react';
|
||||
import Plus from '@/assets/icons/plus.svg?react';
|
||||
import ActionsButton from '@/components/ActionsButton';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import { openIdProviderConfigPath, openIdProviderPath } from '@/consts/oidc';
|
||||
import { AppDataContext } from '@/contexts/AppDataProvider';
|
||||
|
@ -24,20 +22,19 @@ import CopyToClipboard from '@/ds-components/CopyToClipboard';
|
|||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import Table from '@/ds-components/Table';
|
||||
import { type Column } from '@/ds-components/Table/types';
|
||||
import TextLink from '@/ds-components/TextLink';
|
||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
import { type RequestError } from '@/hooks/use-api';
|
||||
import useCustomDomain from '@/hooks/use-custom-domain';
|
||||
|
||||
import CreateSecretModal from './CreateSecretModal';
|
||||
import CreateSecretModal from '../CreateSecretModal';
|
||||
|
||||
import styles from './index.module.scss';
|
||||
import { type ApplicationSecretRow, useSecretTableColumns } from './use-secret-table-columns';
|
||||
|
||||
export { type ApplicationSecretRow } from './use-secret-table-columns';
|
||||
|
||||
const isLegacySecret = (secret: string) => !secret.startsWith(internalPrefix);
|
||||
|
||||
type ApplicationSecretRow = Pick<ApplicationSecret, 'name' | 'value' | 'expiresAt'> & {
|
||||
isLegacy?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
readonly app: Application;
|
||||
readonly oidcConfig: SnakeCaseOidcConfig;
|
||||
|
@ -55,7 +52,6 @@ function EndpointsAndCredentials({
|
|||
const { data: customDomain, applyDomain: applyCustomDomain } = useCustomDomain();
|
||||
const [showCreateSecretModal, setShowCreateSecretModal] = useState(false);
|
||||
const secrets = useSWR<ApplicationSecretRow[], RequestError>(`api/applications/${id}/secrets`);
|
||||
const api = useApi();
|
||||
const shouldShowAppSecrets = hasSecrets(type);
|
||||
|
||||
const toggleShowMoreEndpoints = useCallback(() => {
|
||||
|
@ -80,57 +76,21 @@ function EndpointsAndCredentials({
|
|||
],
|
||||
[secret, secrets.data, t]
|
||||
);
|
||||
const tableColumns: Array<Column<ApplicationSecretRow>> = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('general.name'),
|
||||
dataIndex: 'name',
|
||||
colSpan: 3,
|
||||
render: ({ name }) => <span>{name}</span>,
|
||||
},
|
||||
{
|
||||
title: t('application_details.secrets.value'),
|
||||
dataIndex: 'value',
|
||||
colSpan: 6,
|
||||
render: ({ value }) => (
|
||||
<CopyToClipboard hasVisibilityToggle displayType="block" value={value} variant="text" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('application_details.secrets.expires_at'),
|
||||
dataIndex: 'expiresAt',
|
||||
colSpan: 3,
|
||||
render: ({ expiresAt }) => (
|
||||
<span>
|
||||
{expiresAt
|
||||
? new Date(expiresAt).toLocaleString()
|
||||
: t('application_details.secrets.never')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'actions',
|
||||
render: ({ name, isLegacy }) => (
|
||||
<ActionsButton
|
||||
fieldName="application_details.application_secret"
|
||||
deleteConfirmation="application_details.secrets.delete_confirmation"
|
||||
onDelete={async () => {
|
||||
if (isLegacy) {
|
||||
await api.delete(`api/applications/${id}/legacy-secret`);
|
||||
onApplicationUpdated();
|
||||
} else {
|
||||
await api.delete(`api/applications/${id}/secrets/${encodeURIComponent(name)}`);
|
||||
void secrets.mutate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[api, id, onApplicationUpdated, secrets, t]
|
||||
);
|
||||
|
||||
const onUpdated = useCallback(
|
||||
(isLegacy: boolean) => {
|
||||
if (isLegacy) {
|
||||
onApplicationUpdated();
|
||||
} else {
|
||||
void secrets.mutate();
|
||||
}
|
||||
},
|
||||
[onApplicationUpdated, secrets]
|
||||
);
|
||||
const tableColumns = useSecretTableColumns({
|
||||
appId: id,
|
||||
onUpdated,
|
||||
});
|
||||
return (
|
||||
<FormCard
|
||||
title="application_details.endpoints_and_credentials"
|
|
@ -0,0 +1,100 @@
|
|||
import { type ApplicationSecret } from '@logto/schemas';
|
||||
import { compareDesc } from 'date-fns';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ActionsButton from '@/components/ActionsButton';
|
||||
import CopyToClipboard from '@/ds-components/CopyToClipboard';
|
||||
import { type Column } from '@/ds-components/Table/types';
|
||||
import { Tooltip } from '@/ds-components/Tip';
|
||||
import useApi from '@/hooks/use-api';
|
||||
|
||||
import styles from './index.module.scss';
|
||||
|
||||
export type ApplicationSecretRow = Pick<ApplicationSecret, 'name' | 'value' | 'expiresAt'> & {
|
||||
isLegacy?: boolean;
|
||||
};
|
||||
|
||||
function Expired({ expiresAt }: { readonly expiresAt: Date }) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
return (
|
||||
<Tooltip
|
||||
content={t('application_details.secrets.expired_tooltip', {
|
||||
date: expiresAt.toLocaleString(),
|
||||
})}
|
||||
>
|
||||
<span className={styles.expired}>{t('application_details.secrets.expired')}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
type UseSecretTableColumns = {
|
||||
appId: string;
|
||||
onUpdated: (isLegacy: boolean) => void;
|
||||
};
|
||||
|
||||
export const useSecretTableColumns = ({ appId, onUpdated }: UseSecretTableColumns) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const api = useApi();
|
||||
const tableColumns: Array<Column<ApplicationSecretRow>> = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('general.name'),
|
||||
dataIndex: 'name',
|
||||
colSpan: 3,
|
||||
render: ({ name }) => <span>{name}</span>,
|
||||
},
|
||||
{
|
||||
title: t('application_details.secrets.value'),
|
||||
dataIndex: 'value',
|
||||
colSpan: 6,
|
||||
render: ({ value }) => (
|
||||
<CopyToClipboard
|
||||
hasVisibilityToggle
|
||||
displayType="block"
|
||||
value={value}
|
||||
className={styles.copyToClipboard}
|
||||
variant="text"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('application_details.secrets.expires_at'),
|
||||
dataIndex: 'expiresAt',
|
||||
colSpan: 3,
|
||||
render: ({ expiresAt }) => (
|
||||
<span>
|
||||
{expiresAt ? (
|
||||
compareDesc(expiresAt, new Date()) === 1 ? (
|
||||
<Expired expiresAt={new Date(expiresAt)} />
|
||||
) : (
|
||||
new Date(expiresAt).toLocaleString()
|
||||
)
|
||||
) : (
|
||||
t('application_details.secrets.never')
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'actions',
|
||||
render: ({ name, isLegacy }) => (
|
||||
<ActionsButton
|
||||
fieldName="application_details.application_secret"
|
||||
deleteConfirmation="application_details.secrets.delete_confirmation"
|
||||
onDelete={async () => {
|
||||
await (isLegacy
|
||||
? api.delete(`api/applications/${appId}/legacy-secret`)
|
||||
: api.delete(`api/applications/${appId}/secrets/${encodeURIComponent(name)}`));
|
||||
onUpdated(isLegacy ?? false);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[api, appId, onUpdated, t]
|
||||
);
|
||||
|
||||
return tableColumns;
|
||||
};
|
|
@ -11,15 +11,17 @@ import IconButton from '@/ds-components/IconButton';
|
|||
import Spacer from '@/ds-components/Spacer';
|
||||
|
||||
import AppGuide from '../../components/AppGuide';
|
||||
import { type ApplicationSecretRow } from '../EndpointsAndCredentials';
|
||||
|
||||
import styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
readonly app: ApplicationResponse;
|
||||
readonly secrets: ApplicationSecretRow[];
|
||||
readonly onClose: () => void;
|
||||
};
|
||||
|
||||
function GuideDrawer({ app, onClose }: Props) {
|
||||
function GuideDrawer({ app, secrets, onClose }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.guide' });
|
||||
const { getStructuredAppGuideMetadata } = useAppGuideMetadata();
|
||||
const [selectedGuide, setSelectedGuide] = useState<SelectedGuide>();
|
||||
|
@ -89,6 +91,7 @@ function GuideDrawer({ app, onClose }: Props) {
|
|||
className={styles.guide}
|
||||
guideId={selectedGuide.id}
|
||||
app={app}
|
||||
secrets={secrets}
|
||||
onClose={() => {
|
||||
setSelectedGuide(undefined);
|
||||
}}
|
||||
|
|
|
@ -14,21 +14,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.customEndpointNotes {
|
||||
margin-top: _.unit(6);
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.fieldButton {
|
||||
margin-top: _.unit(2);
|
||||
}
|
||||
|
||||
.trailingIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.tabContainer {
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
|
@ -37,17 +22,3 @@
|
|||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
button.add {
|
||||
margin-top: _.unit(2);
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: _.unit(2);
|
||||
}
|
||||
|
||||
.empty {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text-secondary);
|
||||
margin: _.unit(3) 0;
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@ import { trySubmitSafe } from '@/utils/form';
|
|||
|
||||
import BackchannelLogout from './BackchannelLogout';
|
||||
import Branding from './Branding';
|
||||
import EndpointsAndCredentials from './EndpointsAndCredentials';
|
||||
import EndpointsAndCredentials, { type ApplicationSecretRow } from './EndpointsAndCredentials';
|
||||
import GuideDrawer from './GuideDrawer';
|
||||
import MachineLogs from './MachineLogs';
|
||||
import MachineToMachineApplicationRoles from './MachineToMachineApplicationRoles';
|
||||
|
@ -44,11 +44,12 @@ import { type ApplicationForm, applicationFormDataParser } from './utils';
|
|||
|
||||
type Props = {
|
||||
readonly data: ApplicationResponse;
|
||||
readonly secrets: ApplicationSecretRow[];
|
||||
readonly oidcConfig: SnakeCaseOidcConfig;
|
||||
readonly onApplicationUpdated: () => void;
|
||||
};
|
||||
|
||||
function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: Props) {
|
||||
function ApplicationDetailsContent({ data, secrets, oidcConfig, onApplicationUpdated }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { tab } = useParams();
|
||||
const { navigate } = useTenantPathname();
|
||||
|
@ -154,7 +155,7 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P
|
|||
]}
|
||||
/>
|
||||
<Drawer isOpen={isReadmeOpen} onClose={onCloseDrawer}>
|
||||
<GuideDrawer app={data} onClose={onCloseDrawer} />
|
||||
<GuideDrawer app={data} secrets={secrets} onClose={onCloseDrawer} />
|
||||
</Drawer>
|
||||
<DeleteConfirmModal
|
||||
isOpen={isDeleteFormOpen}
|
||||
|
|
|
@ -4,17 +4,19 @@ import Modal from 'react-modal';
|
|||
import ModalHeader from '@/components/Guide/ModalHeader';
|
||||
import modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
import { type ApplicationSecretRow } from '../ApplicationDetailsContent/EndpointsAndCredentials';
|
||||
import AppGuide from '../components/AppGuide';
|
||||
|
||||
import styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
readonly guideId: string;
|
||||
readonly app?: ApplicationResponse;
|
||||
readonly app: ApplicationResponse;
|
||||
readonly secrets: ApplicationSecretRow[];
|
||||
readonly onClose: () => void;
|
||||
};
|
||||
|
||||
function GuideModal({ guideId, app, onClose }: Props) {
|
||||
function GuideModal({ guideId, app, secrets, onClose }: Props) {
|
||||
return (
|
||||
<Modal shouldCloseOnEsc isOpen className={modalStyles.fullScreen} onRequestClose={onClose}>
|
||||
<div className={styles.modalContainer}>
|
||||
|
@ -27,7 +29,13 @@ function GuideModal({ guideId, app, onClose }: Props) {
|
|||
requestSuccessMessage="guide.request_guide_successfully"
|
||||
onClose={onClose}
|
||||
/>
|
||||
<AppGuide className={styles.guide} guideId={guideId} app={app} onClose={onClose} />
|
||||
<AppGuide
|
||||
className={styles.guide}
|
||||
guideId={guideId}
|
||||
app={app}
|
||||
secrets={secrets}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
@ -7,15 +7,18 @@ import Guide, { GuideContext, type GuideContextType } from '@/components/Guide';
|
|||
import { AppDataContext } from '@/contexts/AppDataProvider';
|
||||
import useCustomDomain from '@/hooks/use-custom-domain';
|
||||
|
||||
import { type ApplicationSecretRow } from '../../ApplicationDetailsContent/EndpointsAndCredentials';
|
||||
|
||||
type Props = {
|
||||
readonly className?: string;
|
||||
readonly guideId: string;
|
||||
readonly app?: ApplicationResponse;
|
||||
readonly app: ApplicationResponse;
|
||||
readonly secrets: ApplicationSecretRow[];
|
||||
readonly isCompact?: boolean;
|
||||
readonly onClose: () => void;
|
||||
};
|
||||
|
||||
function AppGuide({ className, guideId, app, isCompact, onClose }: Props) {
|
||||
function AppGuide({ className, guideId, app, secrets, isCompact, onClose }: Props) {
|
||||
const { tenantEndpoint } = useContext(AppDataContext);
|
||||
const { applyDomain: applyCustomDomain } = useCustomDomain();
|
||||
const guide = guides.find(({ id }) => id === guideId);
|
||||
|
@ -23,18 +26,18 @@ function AppGuide({ className, guideId, app, isCompact, onClose }: Props) {
|
|||
const memorizedContext = useMemo(
|
||||
() =>
|
||||
conditional(
|
||||
!!guide &&
|
||||
!!app && {
|
||||
metadata: guide.metadata,
|
||||
Logo: guide.Logo,
|
||||
app,
|
||||
endpoint: applyCustomDomain(tenantEndpoint?.href ?? ''),
|
||||
redirectUris: app.oidcClientMetadata.redirectUris,
|
||||
postLogoutRedirectUris: app.oidcClientMetadata.postLogoutRedirectUris,
|
||||
isCompact: Boolean(isCompact),
|
||||
}
|
||||
!!guide && {
|
||||
metadata: guide.metadata,
|
||||
Logo: guide.Logo,
|
||||
app,
|
||||
secrets,
|
||||
endpoint: applyCustomDomain(tenantEndpoint?.href ?? ''),
|
||||
redirectUris: app.oidcClientMetadata.redirectUris,
|
||||
postLogoutRedirectUris: app.oidcClientMetadata.postLogoutRedirectUris,
|
||||
isCompact: Boolean(isCompact),
|
||||
}
|
||||
) satisfies GuideContextType | undefined,
|
||||
[guide, app, tenantEndpoint?.href, applyCustomDomain, isCompact]
|
||||
[guide, app, secrets, applyCustomDomain, tenantEndpoint?.href, isCompact]
|
||||
);
|
||||
|
||||
return memorizedContext ? (
|
||||
|
@ -42,7 +45,7 @@ function AppGuide({ className, guideId, app, isCompact, onClose }: Props) {
|
|||
<Guide
|
||||
className={className}
|
||||
guideId={guideId}
|
||||
isEmpty={!app && !guide}
|
||||
isEmpty={!guide}
|
||||
isLoading={!app}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
|
|
@ -5,10 +5,12 @@ import useSWR from 'swr';
|
|||
import DetailsPage from '@/components/DetailsPage';
|
||||
import PageMeta from '@/components/PageMeta';
|
||||
import { openIdProviderConfigPath } from '@/consts/oidc';
|
||||
import { Daisy } from '@/ds-components/Spinner';
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||
|
||||
import ApplicationDetailsContent from './ApplicationDetailsContent';
|
||||
import { type ApplicationSecretRow } from './ApplicationDetailsContent/EndpointsAndCredentials';
|
||||
import GuideModal from './GuideModal';
|
||||
|
||||
function ApplicationDetails() {
|
||||
|
@ -19,21 +21,25 @@ function ApplicationDetails() {
|
|||
const { data, error, mutate } = useSWR<ApplicationResponse, RequestError>(
|
||||
id && `api/applications/${id}`
|
||||
);
|
||||
const secrets = useSWR<ApplicationSecretRow[], RequestError>(`api/applications/${id}/secrets`);
|
||||
const oidcConfig = useSWR<SnakeCaseOidcConfig, RequestError>(openIdProviderConfigPath);
|
||||
|
||||
const {
|
||||
data: oidcConfig,
|
||||
error: fetchOidcConfigError,
|
||||
mutate: mutateOidcConfig,
|
||||
} = useSWR<SnakeCaseOidcConfig, RequestError>(openIdProviderConfigPath);
|
||||
|
||||
const isLoading = (!data && !error) || (!oidcConfig && !fetchOidcConfigError);
|
||||
const requestError = error ?? fetchOidcConfigError;
|
||||
const isLoading =
|
||||
(!data && !error) ||
|
||||
(!oidcConfig.data && !oidcConfig.error) ||
|
||||
(!secrets.data && !secrets.error);
|
||||
const requestError = error ?? oidcConfig.error ?? secrets.error;
|
||||
|
||||
if (isGuideView) {
|
||||
if (!data || !secrets.data) {
|
||||
return <Daisy />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GuideModal
|
||||
guideId={guideId}
|
||||
app={data}
|
||||
secrets={secrets.data}
|
||||
onClose={() => {
|
||||
navigate(`/applications/${id}`);
|
||||
}}
|
||||
|
@ -49,14 +55,16 @@ function ApplicationDetails() {
|
|||
error={requestError}
|
||||
onRetry={() => {
|
||||
void mutate();
|
||||
void mutateOidcConfig();
|
||||
void oidcConfig.mutate();
|
||||
void secrets.mutate();
|
||||
}}
|
||||
>
|
||||
<PageMeta titleKey="application_details.page_title" />
|
||||
{data && oidcConfig && (
|
||||
{data && oidcConfig.data && secrets.data && (
|
||||
<ApplicationDetailsContent
|
||||
data={data}
|
||||
oidcConfig={oidcConfig}
|
||||
oidcConfig={oidcConfig.data}
|
||||
secrets={secrets.data}
|
||||
onApplicationUpdated={mutate}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -13,6 +13,7 @@ import { generateStandardId, generateStandardSecret } from '@logto/shared';
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { boolean, object, string, z } from 'zod';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import koaPagination from '#src/middleware/koa-pagination.js';
|
||||
|
@ -26,8 +27,6 @@ import applicationCustomDataRoutes from './application-custom-data.js';
|
|||
import { generateInternalSecret } from './application-secret.js';
|
||||
import { applicationCreateGuard, applicationPatchGuard } from './types.js';
|
||||
|
||||
import { EnvSet } from '#src/src/env-set/index.js';
|
||||
|
||||
const includesInternalAdminRole = (roles: Readonly<Array<{ role: Role }>>) =>
|
||||
roles.some(({ role: { name } }) => name === InternalRole.Admin);
|
||||
|
||||
|
|
|
@ -172,12 +172,14 @@ const application_details = {
|
|||
delete_confirmation:
|
||||
'This action cannot be undone. Are you sure you want to delete this secret?',
|
||||
legacy_secret: 'Legacy secret',
|
||||
expired: 'Expired',
|
||||
expired_tooltip: 'This secret was expired on {{date}}.',
|
||||
create_modal: {
|
||||
title: 'Create application secret',
|
||||
expiration: 'Expiration',
|
||||
expiration_description: 'The secret will expire at {{date}}.',
|
||||
expiration_description_never:
|
||||
'The secret will never expire. We strongly recommend setting an expiration date.',
|
||||
'The secret will never expire. We recommend setting an expiration date for better security.',
|
||||
days: '{{count}} day',
|
||||
days_other: '{{count}} days',
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue