Skip to content

Commit

Permalink
refactor(console): setup m2m roles after creating m2m app
Browse files Browse the repository at this point in the history
  • Loading branch information
xiaoyijun committed May 27, 2024
1 parent b9b96d2 commit d65f640
Show file tree
Hide file tree
Showing 23 changed files with 266 additions and 48 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ApplicationType, RoleType, type Application } from '@logto/schemas';
import { useCallback, useState } from 'react';

import { isDevFeaturesEnabled } from '@/consts/env';
import AssignToRoleModal from '@/pages/Roles/components/AssignToRoleModal';

import CreateForm, { type Props as CreateApplicationFormProps } from '../CreateForm';

/**
* The component for handling application creation (including creating an application and setup its permissions if needed).
*/
function ApplicationCreation({ onClose, ...reset }: CreateApplicationFormProps) {
const [createdMachineToMachineApplication, setCreatedMachineToMachineApplication] =
useState<Application>();

const closeHandler = useCallback(
(createdApp?: Application) => {
// Todo @xiaoyijun remove dev feature flag
if (isDevFeaturesEnabled && createdApp?.type === ApplicationType.MachineToMachine) {
setCreatedMachineToMachineApplication(createdApp);
return;
}

onClose?.(createdApp);
},
[onClose]
);

if (createdMachineToMachineApplication) {
return (
<AssignToRoleModal
isSkippable
isMachineToMachineRoleCreationHintVisible
entity={createdMachineToMachineApplication}
type={RoleType.MachineToMachine}
modalTextOverrides={{
title: 'applications.m2m_role_assignment.title',
subtitle: 'applications.m2m_role_assignment.subtitle',
}}
onClose={() => {
onClose?.(createdMachineToMachineApplication);
}}
/>
);
}

return <CreateForm {...reset} onClose={closeHandler} />;
}

export default ApplicationCreation;
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type FormData = {
isThirdParty?: boolean;
};

type Props = {
export type Props = {
readonly isDefaultCreateThirdParty?: boolean;
readonly defaultCreateType?: ApplicationType;
readonly defaultCreateFrameworkName?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
import { allAppGuideCategories, type AppGuideCategory } from '@/types/applications';
import { thirdPartyAppCategory } from '@/types/applications';

import CreateForm from '../CreateForm';
import ApplicationCreation from '../ApplicationCreation';
import ProtectedAppCard from '../ProtectedAppCard';

import * as styles from './index.module.scss';
Expand Down Expand Up @@ -185,7 +185,7 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton }: Props) {
</div>
</div>
{selectedGuide?.target !== 'API' && showCreateForm && (
<CreateForm
<ApplicationCreation
defaultCreateType={selectedGuide?.target}
defaultCreateFrameworkName={selectedGuide?.name}
isDefaultCreateThirdParty={selectedGuide?.isThirdParty}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import ModalHeader from '@/components/Guide/ModalHeader';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import * as modalStyles from '@/scss/modal.module.scss';

import CreateForm from '../CreateForm';
import ApplicationCreation from '../ApplicationCreation';
import GuideLibrary from '../GuideLibrary';

import * as styles from './index.module.scss';
Expand Down Expand Up @@ -47,7 +47,7 @@ function GuideLibraryModal({ isOpen, onClose }: Props) {
/>
</div>
{showCreateForm && (
<CreateForm
<ApplicationCreation
onClose={(newApp) => {
if (newApp) {
navigate(`/applications/${newApp.id}`);
Expand Down
4 changes: 2 additions & 2 deletions packages/console/src/pages/GetStarted/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import useTheme from '@/hooks/use-theme';
import useWindowResize from '@/hooks/use-window-resize';

import CreateApiForm from '../ApiResources/components/CreateForm';
import CreateAppForm from '../Applications/components/CreateForm';
import ApplicationCreation from '../Applications/components/ApplicationCreation';

import ProtectedAppCreationForm from './ProtectedAppCreationForm';
import * as styles from './index.module.scss';
Expand Down Expand Up @@ -125,7 +125,7 @@ function GetStarted() {
onClickGuide={onClickAppGuide}
/>
{selectedGuide?.target !== 'API' && showCreateAppForm && (
<CreateAppForm
<ApplicationCreation
defaultCreateType={selectedGuide?.target}
defaultCreateFrameworkName={selectedGuide?.name}
onClose={onCloseCreateAppForm}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@use '@/scss/underscore' as _;

.hint {
margin-top: _.unit(2);
font: var(--font-body-2);
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,60 @@
import { type AdminConsoleKey } from '@logto/phrases';
import type { RoleResponse, UserProfileResponse, Application } from '@logto/schemas';
import { RoleType } from '@logto/schemas';
import { cond } from '@silverhand/essentials';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';

import RolesTransfer from '@/components/RolesTransfer';
import Button from '@/ds-components/Button';
import DangerousRaw from '@/ds-components/DangerousRaw';
import ModalLayout from '@/ds-components/ModalLayout';
import TextLink from '@/ds-components/TextLink';
import useApi from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import { getUserTitle } from '@/utils/user';

type Props =
import * as styles from './index.module.scss';

type Props = (
| {
entity: UserProfileResponse;
onClose: (success?: boolean) => void;
type: RoleType.User;
}
| {
entity: Application;
onClose: (success?: boolean) => void;
type: RoleType.MachineToMachine;
};
}
) & {
readonly onClose: (success?: boolean) => void;
/**
* The overrides for the modal text.
* If specified, the title will be overridden.
* If not specified, the default title will vary based on the type.
*/
readonly modalTextOverrides?: {
title?: AdminConsoleKey;
subtitle?: AdminConsoleKey;
};
readonly isSkippable?: boolean;
readonly isMachineToMachineRoleCreationHintVisible?: boolean;
};

function AssignToRoleModal({ entity, onClose, type }: Props) {
function AssignToRoleModal({
entity,
onClose,
type,
modalTextOverrides,
isSkippable,
isMachineToMachineRoleCreationHintVisible,
}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });

const [isSubmitting, setIsSubmitting] = useState(false);
const [roles, setRoles] = useState<RoleResponse[]>([]);
const isForUser = type === RoleType.User;

const api = useApi();

Expand All @@ -41,12 +66,9 @@ function AssignToRoleModal({ entity, onClose, type }: Props) {
setIsSubmitting(true);

try {
await api.post(
`api/${type === RoleType.User ? 'users' : 'applications'}/${entity.id}/roles`,
{
json: { roleIds: roles.map(({ id }) => id) },
}
);
await api.post(`api/${isForUser ? 'users' : 'applications'}/${entity.id}/roles`, {
json: { roleIds: roles.map(({ id }) => id) },
});
toast.success(t('user_details.roles.role_assigned'));
onClose(true);
} finally {
Expand All @@ -66,40 +88,56 @@ function AssignToRoleModal({ entity, onClose, type }: Props) {
>
<ModalLayout
title={
<DangerousRaw>
{t(
type === RoleType.User
? 'user_details.roles.assign_title'
: 'application_details.roles.assign_title',
{ name: type === RoleType.User ? getUserTitle(entity) : entity.name }
)}
</DangerousRaw>
cond(modalTextOverrides?.title) ?? (
<DangerousRaw>
{t(
isForUser
? 'user_details.roles.assign_title'
: 'application_details.roles.assign_title',
{ name: isForUser ? getUserTitle(entity) : entity.name }
)}
</DangerousRaw>
)
}
subtitle={
<DangerousRaw>
{t(
type === RoleType.User
? 'user_details.roles.assign_subtitle'
: 'application_details.roles.assign_subtitle',
{ name: type === RoleType.User ? getUserTitle(entity) : entity.name }
)}
</DangerousRaw>
cond(modalTextOverrides?.subtitle) ?? (
<DangerousRaw>
{t(
isForUser
? 'user_details.roles.assign_subtitle'
: 'application_details.roles.assign_subtitle',
{ name: isForUser ? getUserTitle(entity) : entity.name }
)}
</DangerousRaw>
)
}
size="large"
footer={
<Button
isLoading={isSubmitting}
disabled={roles.length === 0}
htmlType="submit"
title={
type === RoleType.User
? 'user_details.roles.confirm_assign'
: 'application_details.roles.confirm_assign'
}
size="large"
type="primary"
onClick={handleAssign}
/>
<>
{isSkippable && (
<Button
isLoading={isSubmitting}
title="general.skip"
size="large"
onClick={() => {
onClose();
}}
/>
)}
<Button
isLoading={isSubmitting}
disabled={roles.length === 0}
htmlType="submit"
title={
isForUser
? 'user_details.roles.confirm_assign'
: 'application_details.roles.confirm_assign'
}
size="large"
type="primary"
onClick={handleAssign}
/>
</>
}
onClose={onClose}
>
Expand All @@ -111,6 +149,17 @@ function AssignToRoleModal({ entity, onClose, type }: Props) {
setRoles(value);
}}
/>
{!isForUser && isMachineToMachineRoleCreationHintVisible && (
<div className={styles.hint}>
<Trans
components={{
a: <TextLink to="/roles" />,
}}
>
{t('applications.m2m_role_assignment.role_creation_hint')}
</Trans>
</div>
)}
</ModalLayout>
</ReactModal>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,27 @@ describe('applications', () => {

await waitForToast(page, { text: 'Application created successfully.' });

// Expect to assign management API access role for the M2M app
if (app.type === ApplicationType.MachineToMachine) {
await expectModalWithTitle(
page,
'Authorize this app with a machine-to-machine role to protect your API'
);

await expect(page).toClick(
'.ReactModalPortal div[class$=rolesTransfer] div[class$=item] div',
{
text: 'Logto Management API access',
}
);

await expectToClickModalAction(page, 'Assign roles');

await waitForToast(page, {
text: 'Successfully assigned role(s)',
});
}

await expect(page).toMatchElement('div[class$=main] div[class$=header] div[class$=name]', {
text: app.name,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ const applications = {
placeholder_title: 'Wähle einen Anwendungstyp, um fortzufahren',
placeholder_description:
'Logto verwendet eine Anwendungs-Entität für OIDC, um Aufgaben wie die Identifizierung Ihrer Apps, die Verwaltung der Anmeldung und die Erstellung von Prüfprotokollen zu erleichtern.',
m2m_role_assignment: {
title:
'Autorisieren Sie diese App mit einer Maschine-zu-Maschine-Rolle, um Ihre API zu schützen',
subtitle:
'Maschine-zu-Maschine-Anwendungen erfordern eine autorisierte Maschine-zu-Maschine-Rolle.',
role_creation_hint:
'Hat keine Maschine-zu-Maschine-Rolle? <a>Erstellen Sie zuerst eine Maschine-zu-Maschine</a>-Rolle',
},
};

export default Object.freeze(applications);
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ const applications = {
placeholder_title: 'Select an application type to continue',
placeholder_description:
'Logto uses an application entity for OIDC to help with tasks such as identifying your apps, managing sign-in, and creating audit logs.',
m2m_role_assignment: {
title: 'Authorize this app with a machine-to-machine role to protect your API',
subtitle: 'Machine-to-machine applications require authorized machine-to-machine role.',
role_creation_hint:
'Doesn’t have a machine-to-machine role? <a>Create a machine-to-machine</a> role first',
},
};

export default Object.freeze(applications);
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ const applications = {
placeholder_title: 'Selecciona un tipo de aplicación para continuar',
placeholder_description:
'Logto utiliza una entidad de aplicación para OIDC para ayudar con tareas como la identificación de tus aplicaciones, la gestión de inicio de sesión y la creación de registros de auditoría.',
m2m_role_assignment: {
title: 'Autorice esta aplicación con un rol de máquina a máquina para proteger su API',
subtitle:
'Las aplicaciones de máquina a máquina requieren un rol de máquina a máquina autorizado.',
role_creation_hint:
'¿No tiene un rol de máquina a máquina? <a>Cree primero un rol de máquina a máquina</a>',
},
};

export default Object.freeze(applications);
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ const applications = {
placeholder_title: "Sélectionnez un type d'application pour continuer",
placeholder_description:
"Logto utilise une entité d'application pour OIDC pour aider aux tâches telles que l'identification de vos applications, la gestion de la connexion et la création de journaux d'audit.",
m2m_role_assignment: {
title: 'Autoriser cette application avec un rôle machine à machine pour protéger votre API',
subtitle: 'Les applications machine à machine nécessitent un rôle machine à machine autorisé.',
role_creation_hint:
'N’a pas de rôle machine à machine ? <a>Créez d’abord un rôle machine à machine</a>',
},
};

export default Object.freeze(applications);
Loading

0 comments on commit d65f640

Please sign in to comment.