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

Merge pull request from logto-io/gao-improve-app-secrets-ux

refactor(console): improve ux
This commit is contained in:
Gao Sun 2024-07-29 18:58:35 +08:00 committed by GitHub
commit 81b5a7cf96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 285 additions and 176 deletions
packages
console/src
assets/docs/guides
web-dotnet-core-mvc/fragments
web-express
web-go
web-java-spring-boot
web-next-app-router
web-next-auth
web-next
web-nuxt
web-php
web-python
web-ruby
web-sveltekit
components
DateTime
Guide
ds-components/CopyToClipboard
mdx-components/ApplicationCredentials
pages
ApplicationDetails
OrganizationDetails/Members
RoleDetails/RoleUsers
Users
phrases/src/locales/en/translation/admin-console

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,18 +1,30 @@
import type { Nullable } from '@silverhand/essentials';
import { type Nullable } from '@silverhand/essentials';
import { isValid } from 'date-fns';
type Props = {
readonly children: Nullable<string | number>;
};
function DateTime({ children }: Props) {
const date = children && new Date(children);
if (!date || !isValid(date)) {
return <span>-</span>;
const parseDate = (date: Nullable<string | number | Date>) => {
if (!date) {
return;
}
return <span>{date.toLocaleDateString()}</span>;
const parsed = new Date(date);
return isValid(parsed) ? parsed : undefined;
};
type Props = {
readonly children: Nullable<string | number | Date>;
};
/**
* Safely display a date in the user's locale. If the date is invalid, it will display a dash.
*/
export function LocaleDate({ children }: Props) {
return <span>{parseDate(children)?.toLocaleDateString() ?? '-'}</span>;
}
export default DateTime;
/**
* Safely display a date and time in the user's locale. If the date is invalid, it will display a
* dash.
*/
export function LocaleDateTime({ children }: Props) {
return <span>{parseDate(children)?.toLocaleString() ?? '-'}</span>;
}

View file

@ -7,6 +7,7 @@ import { type GuideMetadata } from '@/assets/docs/guides/types';
import Button from '@/ds-components/Button';
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
import MdxProvider from '@/mdx-components/MdxProvider';
import { type ApplicationSecretRow } from '@/pages/ApplicationDetails/ApplicationDetailsContent/EndpointsAndCredentials';
import NotFound from '@/pages/NotFound';
import StepsSkeleton from './StepsSkeleton';
@ -19,6 +20,7 @@ export type GuideContextType = {
| ((props: { readonly className?: string }) => JSX.Element);
isCompact: boolean;
app?: ApplicationResponse;
secrets?: ApplicationSecretRow[];
endpoint?: string;
redirectUris?: string[];
postLogoutRedirectUris?: string[];
@ -40,6 +42,7 @@ export const GuideContext = createContext<GuideContextType>({
metadata: {} as GuideMetadata,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, no-restricted-syntax
app: {} as ApplicationResponse,
secrets: [],
endpoint: '',
redirectUris: [],
postLogoutRedirectUris: [],

View file

@ -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,8 @@
}
}
}
.dot {
-webkit-text-stroke: 1px var(--color-text);
}
}

View file

@ -60,7 +60,7 @@ function CopyToClipboard(
return value;
}
return '•'.repeat(value.length);
return <span className={styles.dot}>{'•'.repeat(value.length)}</span>;
}, [hasVisibilityToggle, showHiddenContent, value]);
useEffect(() => {
@ -107,7 +107,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 +124,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}

View file

@ -9,8 +9,8 @@ import TextLink from '@/ds-components/TextLink';
import styles from './index.module.scss';
function ApplicationCredentials() {
const { app } = useContext(GuideContext);
const { id, secret } = app ?? {};
const { app, secrets } = useContext(GuideContext);
const { id } = app ?? {};
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
@ -37,12 +37,12 @@ function ApplicationCredentials() {
<CopyToClipboard displayType="block" value={id} variant="border" />
</FormField>
)}
{secret && (
{secrets?.[0] && (
<FormField title="application_details.application_secret">
<CopyToClipboard
hasVisibilityToggle
displayType="block"
value={secret}
value={secrets[0].value}
variant="border"
/>
</FormField>

View file

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

View file

@ -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-placeholder);
}
.copyToClipboard {
width: fit-content;
}

View file

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

View file

@ -0,0 +1,101 @@
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 { LocaleDateTime } from '@/components/DateTime';
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)} />
) : (
<LocaleDateTime>{expiresAt}</LocaleDateTime>
)
) : (
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;
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ import useSWR from 'swr';
import Plus from '@/assets/icons/plus.svg?react';
import ActionsButton from '@/components/ActionsButton';
import DateTime from '@/components/DateTime';
import { LocaleDate } from '@/components/DateTime';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import UserPreview from '@/components/ItemPreview/UserPreview';
import { RoleOption } from '@/components/OrganizationRolesSelect';
@ -96,7 +96,7 @@ function Members() {
dataIndex: 'lastSignInAt',
title: t('users.latest_sign_in'),
colSpan: 5,
render: ({ lastSignInAt }) => <DateTime>{lastSignInAt}</DateTime>,
render: ({ lastSignInAt }) => <LocaleDate>{lastSignInAt}</LocaleDate>,
},
{
dataIndex: 'actions',

View file

@ -9,7 +9,7 @@ import useSWR from 'swr';
import Delete from '@/assets/icons/delete.svg?react';
import Plus from '@/assets/icons/plus.svg?react';
import ApplicationName from '@/components/ApplicationName';
import DateTime from '@/components/DateTime';
import { LocaleDate } from '@/components/DateTime';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import UserPreview from '@/components/ItemPreview/UserPreview';
import { defaultPageSize } from '@/consts';
@ -103,7 +103,7 @@ function RoleUsers() {
title: t('role_details.users.latest_sign_in_column'),
dataIndex: 'latestSignIn',
colSpan: 5,
render: ({ lastSignInAt }) => <DateTime>{lastSignInAt}</DateTime>,
render: ({ lastSignInAt }) => <LocaleDate>{lastSignInAt}</LocaleDate>,
},
{
title: null,

View file

@ -8,7 +8,7 @@ import Plus from '@/assets/icons/plus.svg?react';
import UsersEmptyDark from '@/assets/images/users-empty-dark.svg?react';
import UsersEmpty from '@/assets/images/users-empty.svg?react';
import ApplicationName from '@/components/ApplicationName';
import DateTime from '@/components/DateTime';
import { LocaleDate } from '@/components/DateTime';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import ItemPreview from '@/components/ItemPreview';
import ListPage from '@/components/ListPage';
@ -104,7 +104,7 @@ function Users() {
title: t('users.latest_sign_in'),
dataIndex: 'lastSignInAt',
colSpan: 5,
render: ({ lastSignInAt }) => <DateTime>{lastSignInAt}</DateTime>,
render: ({ lastSignInAt }) => <LocaleDate>{lastSignInAt}</LocaleDate>,
},
],
filter: (

View file

@ -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 enhanced security.',
days: '{{count}} day',
days_other: '{{count}} days',
},