0
Fork 0
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:
Gao Sun 2024-07-29 18:59:17 +08:00 committed by GitHub
parent 81b5a7cf96
commit 40a8a9a9bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 237 additions and 19 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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/';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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