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>();
toast.success(
t('organization_template.roles.create_modal.created', { name: createdData.name })
t('application_details.secrets.create_modal.created', { name: createdData.name })
);
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 CreateSecretModal from '../CreateSecretModal';
import EditSecretModal from '../EditSecretModal';
import styles from './index.module.scss';
import { type ApplicationSecretRow, useSecretTableColumns } from './use-secret-table-columns';
@ -51,6 +52,7 @@ function EndpointsAndCredentials({
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data: customDomain, applyDomain: applyCustomDomain } = useCustomDomain();
const [showCreateSecretModal, setShowCreateSecretModal] = useState(false);
const [editSecret, setEditSecret] = useState<ApplicationSecretRow>();
const secrets = useSWR<ApplicationSecretRow[], RequestError>(`api/applications/${id}/secrets`);
const shouldShowAppSecrets = hasSecrets(type);
@ -87,9 +89,13 @@ function EndpointsAndCredentials({
},
[onApplicationUpdated, secrets]
);
const onEditSecret = useCallback((secret: ApplicationSecretRow) => {
setEditSecret(secret);
}, []);
const tableColumns = useSecretTableColumns({
appId: id,
onUpdated,
onEdit: onEditSecret,
});
return (
<FormCard
@ -242,11 +248,26 @@ function EndpointsAndCredentials({
<CreateSecretModal
appId={id}
isOpen={showCreateSecretModal}
onClose={() => {
onClose={(created) => {
if (created) {
void secrets.mutate();
}
setShowCreateSecretModal(false);
void secrets.mutate();
}}
/>
{editSecret && (
<EditSecretModal
isOpen
appId={id}
secret={editSecret}
onClose={(updated) => {
if (updated) {
void secrets.mutate();
}
setEditSecret(undefined);
}}
/>
)}
</FormField>
)}
</FormCard>

View file

@ -16,6 +16,8 @@ export type ApplicationSecretRow = Pick<ApplicationSecret, 'name' | 'value' | 'e
isLegacy?: boolean;
};
const isExpired = (expiresAt: Date | number) => compareDesc(expiresAt, new Date()) === 1;
function Expired({ expiresAt }: { readonly expiresAt: Date }) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
@ -31,10 +33,11 @@ function Expired({ expiresAt }: { readonly expiresAt: Date }) {
type UseSecretTableColumns = {
appId: string;
onEdit: (secret: ApplicationSecretRow) => 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 api = useApi();
const tableColumns: Array<Column<ApplicationSecretRow>> = useMemo(
@ -66,7 +69,7 @@ export const useSecretTableColumns = ({ appId, onUpdated }: UseSecretTableColumn
render: ({ expiresAt }) => (
<span>
{expiresAt ? (
compareDesc(expiresAt, new Date()) === 1 ? (
isExpired(expiresAt) ? (
<Expired expiresAt={new Date(expiresAt)} />
) : (
<LocaleDateTime>{expiresAt}</LocaleDateTime>
@ -80,21 +83,31 @@ export const useSecretTableColumns = ({ appId, onUpdated }: UseSecretTableColumn
{
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);
}}
/>
),
render: (secret) => {
const { expiresAt, isLegacy, name } = secret;
return (
<ActionsButton
fieldName="application_details.application_secret"
deleteConfirmation="application_details.secrets.delete_confirmation"
onEdit={
(isLegacy ?? false) || (expiresAt && isExpired(expiresAt))
? 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;

View file

@ -38,7 +38,13 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
const coreOrigins = urlSet.origins;
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';
/** Google Sign-In (GSI) origin for Google One Tap. */
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 { convertToIdentifiers } from '#src/utils/sql.js';
import { buildUpdateWhereWithPool } from '../database/update-where.js';
type ApplicationCredentials = ApplicationSecret & {
/** The original application secret that stored in the `applications` table. */
originalSecret: string;
@ -17,6 +19,8 @@ export class ApplicationSecretQueries {
returning: true,
});
public readonly update = buildUpdateWhereWithPool(this.pool)(ApplicationSecrets, true);
constructor(public readonly pool: CommonQueryMethods) {}
async findByCredentials(appId: string, appSecret: string) {

View file

@ -69,6 +69,35 @@
"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();
}
);
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) =>
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) =>
authedAdminApi.delete(`applications/${applicationId}/legacy-secret`);

View file

@ -8,6 +8,7 @@ import {
deleteApplication,
deleteApplicationSecret,
getApplicationSecrets,
updateApplicationSecret,
} from '#src/api/application.js';
import { randomString } from '#src/utils.js';
@ -157,4 +158,23 @@ describe('application secrets', () => {
]);
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.',
days: '{{count}} day',
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.',
},
},
};