mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat: allow app secret edit (#6352)
This commit is contained in:
parent
81b5a7cf96
commit
40a8a9a9bb
11 changed files with 237 additions and 19 deletions
|
@ -74,7 +74,7 @@ function CreateSecretModal({ appId, isOpen, onClose }: Props) {
|
||||||
})
|
})
|
||||||
.json<ApplicationSecret>();
|
.json<ApplicationSecret>();
|
||||||
toast.success(
|
toast.success(
|
||||||
t('organization_template.roles.create_modal.created', { name: createdData.name })
|
t('application_details.secrets.create_modal.created', { name: createdData.name })
|
||||||
);
|
);
|
||||||
onCloseHandler(createdData);
|
onCloseHandler(createdData);
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { type ApplicationSecret } from '@logto/schemas';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import ReactModal from 'react-modal';
|
||||||
|
|
||||||
|
import Button from '@/ds-components/Button';
|
||||||
|
import FormField from '@/ds-components/FormField';
|
||||||
|
import ModalLayout from '@/ds-components/ModalLayout';
|
||||||
|
import TextInput from '@/ds-components/TextInput';
|
||||||
|
import useApi from '@/hooks/use-api';
|
||||||
|
import modalStyles from '@/scss/modal.module.scss';
|
||||||
|
import { trySubmitSafe } from '@/utils/form';
|
||||||
|
|
||||||
|
import { type ApplicationSecretRow } from './EndpointsAndCredentials/use-secret-table-columns';
|
||||||
|
|
||||||
|
type FormData = { name: string; expiration: string };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
readonly appId: string;
|
||||||
|
readonly secret: ApplicationSecretRow;
|
||||||
|
readonly isOpen: boolean;
|
||||||
|
readonly onClose: (updated: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function EditSecretModal({ appId, secret, isOpen, onClose }: Props) {
|
||||||
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
} = useForm<FormData>({ defaultValues: { name: secret.name } });
|
||||||
|
const onCloseHandler = useCallback(
|
||||||
|
(updated?: boolean) => {
|
||||||
|
reset();
|
||||||
|
onClose(updated ?? false);
|
||||||
|
},
|
||||||
|
[onClose, reset]
|
||||||
|
);
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
const submit = handleSubmit(
|
||||||
|
trySubmitSafe(async (data) => {
|
||||||
|
const createdData = await api
|
||||||
|
.patch(`api/applications/${appId}/secrets/${encodeURIComponent(secret.name)}`, {
|
||||||
|
json: data,
|
||||||
|
})
|
||||||
|
.json<ApplicationSecret>();
|
||||||
|
toast.success(t('application_details.secrets.edit_modal.edited', { name: createdData.name }));
|
||||||
|
onCloseHandler(true);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
className={modalStyles.content}
|
||||||
|
overlayClassName={modalStyles.overlay}
|
||||||
|
onRequestClose={() => {
|
||||||
|
onCloseHandler();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalLayout
|
||||||
|
title="application_details.secrets.edit_modal.title"
|
||||||
|
footer={
|
||||||
|
<Button type="primary" title="general.save" isLoading={isSubmitting} onClick={submit} />
|
||||||
|
}
|
||||||
|
onClose={onCloseHandler}
|
||||||
|
>
|
||||||
|
<FormField isRequired title="general.name">
|
||||||
|
<TextInput
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||||
|
autoFocus
|
||||||
|
placeholder="My secret"
|
||||||
|
error={Boolean(errors.name)}
|
||||||
|
{...register('name', { required: true })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</ModalLayout>
|
||||||
|
</ReactModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditSecretModal;
|
|
@ -27,6 +27,7 @@ import { type RequestError } from '@/hooks/use-api';
|
||||||
import useCustomDomain from '@/hooks/use-custom-domain';
|
import useCustomDomain from '@/hooks/use-custom-domain';
|
||||||
|
|
||||||
import CreateSecretModal from '../CreateSecretModal';
|
import CreateSecretModal from '../CreateSecretModal';
|
||||||
|
import EditSecretModal from '../EditSecretModal';
|
||||||
|
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
import { type ApplicationSecretRow, useSecretTableColumns } from './use-secret-table-columns';
|
import { type ApplicationSecretRow, useSecretTableColumns } from './use-secret-table-columns';
|
||||||
|
@ -51,6 +52,7 @@ function EndpointsAndCredentials({
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const { data: customDomain, applyDomain: applyCustomDomain } = useCustomDomain();
|
const { data: customDomain, applyDomain: applyCustomDomain } = useCustomDomain();
|
||||||
const [showCreateSecretModal, setShowCreateSecretModal] = useState(false);
|
const [showCreateSecretModal, setShowCreateSecretModal] = useState(false);
|
||||||
|
const [editSecret, setEditSecret] = useState<ApplicationSecretRow>();
|
||||||
const secrets = useSWR<ApplicationSecretRow[], RequestError>(`api/applications/${id}/secrets`);
|
const secrets = useSWR<ApplicationSecretRow[], RequestError>(`api/applications/${id}/secrets`);
|
||||||
const shouldShowAppSecrets = hasSecrets(type);
|
const shouldShowAppSecrets = hasSecrets(type);
|
||||||
|
|
||||||
|
@ -87,9 +89,13 @@ function EndpointsAndCredentials({
|
||||||
},
|
},
|
||||||
[onApplicationUpdated, secrets]
|
[onApplicationUpdated, secrets]
|
||||||
);
|
);
|
||||||
|
const onEditSecret = useCallback((secret: ApplicationSecretRow) => {
|
||||||
|
setEditSecret(secret);
|
||||||
|
}, []);
|
||||||
const tableColumns = useSecretTableColumns({
|
const tableColumns = useSecretTableColumns({
|
||||||
appId: id,
|
appId: id,
|
||||||
onUpdated,
|
onUpdated,
|
||||||
|
onEdit: onEditSecret,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<FormCard
|
<FormCard
|
||||||
|
@ -242,11 +248,26 @@ function EndpointsAndCredentials({
|
||||||
<CreateSecretModal
|
<CreateSecretModal
|
||||||
appId={id}
|
appId={id}
|
||||||
isOpen={showCreateSecretModal}
|
isOpen={showCreateSecretModal}
|
||||||
onClose={() => {
|
onClose={(created) => {
|
||||||
|
if (created) {
|
||||||
|
void secrets.mutate();
|
||||||
|
}
|
||||||
setShowCreateSecretModal(false);
|
setShowCreateSecretModal(false);
|
||||||
void secrets.mutate();
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{editSecret && (
|
||||||
|
<EditSecretModal
|
||||||
|
isOpen
|
||||||
|
appId={id}
|
||||||
|
secret={editSecret}
|
||||||
|
onClose={(updated) => {
|
||||||
|
if (updated) {
|
||||||
|
void secrets.mutate();
|
||||||
|
}
|
||||||
|
setEditSecret(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
|
@ -16,6 +16,8 @@ export type ApplicationSecretRow = Pick<ApplicationSecret, 'name' | 'value' | 'e
|
||||||
isLegacy?: boolean;
|
isLegacy?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isExpired = (expiresAt: Date | number) => compareDesc(expiresAt, new Date()) === 1;
|
||||||
|
|
||||||
function Expired({ expiresAt }: { readonly expiresAt: Date }) {
|
function Expired({ expiresAt }: { readonly expiresAt: Date }) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
return (
|
return (
|
||||||
|
@ -31,10 +33,11 @@ function Expired({ expiresAt }: { readonly expiresAt: Date }) {
|
||||||
|
|
||||||
type UseSecretTableColumns = {
|
type UseSecretTableColumns = {
|
||||||
appId: string;
|
appId: string;
|
||||||
|
onEdit: (secret: ApplicationSecretRow) => void;
|
||||||
onUpdated: (isLegacy: boolean) => void;
|
onUpdated: (isLegacy: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSecretTableColumns = ({ appId, onUpdated }: UseSecretTableColumns) => {
|
export const useSecretTableColumns = ({ appId, onUpdated, onEdit }: UseSecretTableColumns) => {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const tableColumns: Array<Column<ApplicationSecretRow>> = useMemo(
|
const tableColumns: Array<Column<ApplicationSecretRow>> = useMemo(
|
||||||
|
@ -66,7 +69,7 @@ export const useSecretTableColumns = ({ appId, onUpdated }: UseSecretTableColumn
|
||||||
render: ({ expiresAt }) => (
|
render: ({ expiresAt }) => (
|
||||||
<span>
|
<span>
|
||||||
{expiresAt ? (
|
{expiresAt ? (
|
||||||
compareDesc(expiresAt, new Date()) === 1 ? (
|
isExpired(expiresAt) ? (
|
||||||
<Expired expiresAt={new Date(expiresAt)} />
|
<Expired expiresAt={new Date(expiresAt)} />
|
||||||
) : (
|
) : (
|
||||||
<LocaleDateTime>{expiresAt}</LocaleDateTime>
|
<LocaleDateTime>{expiresAt}</LocaleDateTime>
|
||||||
|
@ -80,21 +83,31 @@ export const useSecretTableColumns = ({ appId, onUpdated }: UseSecretTableColumn
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
dataIndex: 'actions',
|
dataIndex: 'actions',
|
||||||
render: ({ name, isLegacy }) => (
|
render: (secret) => {
|
||||||
<ActionsButton
|
const { expiresAt, isLegacy, name } = secret;
|
||||||
fieldName="application_details.application_secret"
|
return (
|
||||||
deleteConfirmation="application_details.secrets.delete_confirmation"
|
<ActionsButton
|
||||||
onDelete={async () => {
|
fieldName="application_details.application_secret"
|
||||||
await (isLegacy
|
deleteConfirmation="application_details.secrets.delete_confirmation"
|
||||||
? api.delete(`api/applications/${appId}/legacy-secret`)
|
onEdit={
|
||||||
: api.delete(`api/applications/${appId}/secrets/${encodeURIComponent(name)}`));
|
(isLegacy ?? false) || (expiresAt && isExpired(expiresAt))
|
||||||
onUpdated(isLegacy ?? false);
|
? undefined
|
||||||
}}
|
: () => {
|
||||||
/>
|
onEdit(secret);
|
||||||
),
|
}
|
||||||
|
}
|
||||||
|
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]
|
[api, appId, onEdit, onUpdated, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
return tableColumns;
|
return tableColumns;
|
||||||
|
|
|
@ -38,7 +38,13 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
|
||||||
const coreOrigins = urlSet.origins;
|
const coreOrigins = urlSet.origins;
|
||||||
const developmentOrigins = isProduction
|
const developmentOrigins = isProduction
|
||||||
? []
|
? []
|
||||||
: ['ws:', ...['6001', '6002', '6003'].map((port) => `ws://localhost:${port}`)];
|
: [
|
||||||
|
'ws:',
|
||||||
|
...['6001', '6002', '6003'].flatMap((port) => [
|
||||||
|
`ws://localhost:${port}`,
|
||||||
|
`http://localhost:${port}`,
|
||||||
|
]),
|
||||||
|
];
|
||||||
const logtoOrigin = 'https://*.logto.io';
|
const logtoOrigin = 'https://*.logto.io';
|
||||||
/** Google Sign-In (GSI) origin for Google One Tap. */
|
/** Google Sign-In (GSI) origin for Google One Tap. */
|
||||||
const gsiOrigin = 'https://accounts.google.com/gsi/';
|
const gsiOrigin = 'https://accounts.google.com/gsi/';
|
||||||
|
|
|
@ -5,6 +5,8 @@ import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
|
||||||
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
||||||
import { convertToIdentifiers } from '#src/utils/sql.js';
|
import { convertToIdentifiers } from '#src/utils/sql.js';
|
||||||
|
|
||||||
|
import { buildUpdateWhereWithPool } from '../database/update-where.js';
|
||||||
|
|
||||||
type ApplicationCredentials = ApplicationSecret & {
|
type ApplicationCredentials = ApplicationSecret & {
|
||||||
/** The original application secret that stored in the `applications` table. */
|
/** The original application secret that stored in the `applications` table. */
|
||||||
originalSecret: string;
|
originalSecret: string;
|
||||||
|
@ -17,6 +19,8 @@ export class ApplicationSecretQueries {
|
||||||
returning: true,
|
returning: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public readonly update = buildUpdateWhereWithPool(this.pool)(ApplicationSecrets, true);
|
||||||
|
|
||||||
constructor(public readonly pool: CommonQueryMethods) {}
|
constructor(public readonly pool: CommonQueryMethods) {}
|
||||||
|
|
||||||
async findByCredentials(appId: string, appSecret: string) {
|
async findByCredentials(appId: string, appSecret: string) {
|
||||||
|
|
|
@ -69,6 +69,35 @@
|
||||||
"description": "The secret was deleted successfully."
|
"description": "The secret was deleted successfully."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"summary": "Update application secret",
|
||||||
|
"description": "Update a secret for the application by name.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The name of the secret."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"description": "The secret name to update. Must be unique within the application."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "The secret was updated successfully."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -104,4 +104,27 @@ export default function applicationSecretRoutes<T extends ManagementApiRouter>(
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
'/applications/:id/secrets/:name',
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({ id: z.string(), name: z.string() }),
|
||||||
|
body: ApplicationSecrets.updateGuard.pick({ name: true }).required(),
|
||||||
|
response: ApplicationSecrets.guard,
|
||||||
|
status: [200, 400, 404],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const {
|
||||||
|
params: { id: appId, name },
|
||||||
|
body,
|
||||||
|
} = ctx.guard;
|
||||||
|
|
||||||
|
ctx.body = await queries.applicationSecrets.update({
|
||||||
|
where: { applicationId: appId, name },
|
||||||
|
set: body,
|
||||||
|
jsonbMode: 'replace',
|
||||||
|
});
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -148,6 +148,17 @@ export const getApplicationSecrets = async (applicationId: string) =>
|
||||||
export const deleteApplicationSecret = async (applicationId: string, secretName: string) =>
|
export const deleteApplicationSecret = async (applicationId: string, secretName: string) =>
|
||||||
authedAdminApi.delete(`applications/${applicationId}/secrets/${secretName}`);
|
authedAdminApi.delete(`applications/${applicationId}/secrets/${secretName}`);
|
||||||
|
|
||||||
|
export const updateApplicationSecret = async (
|
||||||
|
applicationId: string,
|
||||||
|
secretName: string,
|
||||||
|
body: Record<string, unknown>
|
||||||
|
) =>
|
||||||
|
authedAdminApi
|
||||||
|
.patch(`applications/${applicationId}/secrets/${secretName}`, {
|
||||||
|
json: body,
|
||||||
|
})
|
||||||
|
.json<ApplicationSecret>();
|
||||||
|
|
||||||
export const deleteLegacyApplicationSecret = async (applicationId: string) =>
|
export const deleteLegacyApplicationSecret = async (applicationId: string) =>
|
||||||
authedAdminApi.delete(`applications/${applicationId}/legacy-secret`);
|
authedAdminApi.delete(`applications/${applicationId}/legacy-secret`);
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
deleteApplication,
|
deleteApplication,
|
||||||
deleteApplicationSecret,
|
deleteApplicationSecret,
|
||||||
getApplicationSecrets,
|
getApplicationSecrets,
|
||||||
|
updateApplicationSecret,
|
||||||
} from '#src/api/application.js';
|
} from '#src/api/application.js';
|
||||||
import { randomString } from '#src/utils.js';
|
import { randomString } from '#src/utils.js';
|
||||||
|
|
||||||
|
@ -157,4 +158,23 @@ describe('application secrets', () => {
|
||||||
]);
|
]);
|
||||||
expect(await getApplicationSecrets(application.id)).toEqual([]);
|
expect(await getApplicationSecrets(application.id)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should be able to update application secret', async () => {
|
||||||
|
const application = await createApplication('application', ApplicationType.MachineToMachine);
|
||||||
|
const secretName = randomString();
|
||||||
|
await createApplicationSecret({
|
||||||
|
applicationId: application.id,
|
||||||
|
name: secretName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newSecretName = randomString();
|
||||||
|
const updatedSecret = await updateApplicationSecret(application.id, secretName, {
|
||||||
|
name: newSecretName,
|
||||||
|
});
|
||||||
|
expect(updatedSecret).toEqual(
|
||||||
|
expect.objectContaining({ applicationId: application.id, name: newSecretName })
|
||||||
|
);
|
||||||
|
|
||||||
|
await deleteApplicationSecret(application.id, newSecretName);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -182,6 +182,11 @@ const application_details = {
|
||||||
'The secret will never expire. We recommend setting an expiration date for enhanced security.',
|
'The secret will never expire. We recommend setting an expiration date for enhanced security.',
|
||||||
days: '{{count}} day',
|
days: '{{count}} day',
|
||||||
days_other: '{{count}} days',
|
days_other: '{{count}} days',
|
||||||
|
created: 'The secret {{name}} has been successfully created.',
|
||||||
|
},
|
||||||
|
edit_modal: {
|
||||||
|
title: 'Edit application secret',
|
||||||
|
edited: 'The secret {{name}} has been successfully edited.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue