mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(console): init organization details
This commit is contained in:
parent
23dc01c091
commit
0db5e9f1ce
8 changed files with 508 additions and 0 deletions
|
@ -0,0 +1,27 @@
|
|||
import { type OrganizationScope } from '@logto/schemas';
|
||||
|
||||
import MultiSelect, { type Option } from '@/ds-components/Select/MultiSelect';
|
||||
import useSearchValues from '@/hooks/use-search-values';
|
||||
|
||||
type Props = {
|
||||
value: Array<Option<string>>;
|
||||
onChange: (value: Array<Option<string>>) => void;
|
||||
keyword: string;
|
||||
setKeyword: (keyword: string) => void;
|
||||
};
|
||||
|
||||
function OrganizationRolesSelect({ value, onChange, keyword, setKeyword }: Props) {
|
||||
const { data: scopes } = useSearchValues<OrganizationScope>('api/organization-roles', keyword);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
value={value}
|
||||
options={scopes.map(({ id, name }) => ({ value: id, title: name }))}
|
||||
placeholder="organizations.search_permission_placeholder"
|
||||
onChange={onChange}
|
||||
onSearch={setKeyword}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrganizationRolesSelect;
|
|
@ -26,6 +26,7 @@ import Dashboard from '@/pages/Dashboard';
|
|||
import GetStarted from '@/pages/GetStarted';
|
||||
import Mfa from '@/pages/Mfa';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
import OrganizationDetails from '@/pages/OrganizationDetails';
|
||||
import Organizations from '@/pages/Organizations';
|
||||
import Profile from '@/pages/Profile';
|
||||
import ChangePasswordModal from '@/pages/Profile/containers/ChangePasswordModal';
|
||||
|
@ -155,6 +156,7 @@ function ConsoleContent() {
|
|||
<Route index element={<Organizations />} />
|
||||
<Route path="create" element={<Organizations />} />
|
||||
<Route path="settings" element={<Organizations tab="settings" />} />
|
||||
<Route path=":id/*" element={<OrganizationDetails />} />
|
||||
</Route>
|
||||
)}
|
||||
<Route path="profile">
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
import { type OrganizationRole, type UserWithOrganizationRoles } from '@logto/schemas';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactModal from 'react-modal';
|
||||
|
||||
import OrganizationRolesSelect from '@/components/OrganizationRolesSelect';
|
||||
import Button from '@/ds-components/Button';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import ModalLayout from '@/ds-components/ModalLayout';
|
||||
import { type Option } from '@/ds-components/Select/MultiSelect';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useSearchValues from '@/hooks/use-search-values';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { decapitalize } from '@/utils/string';
|
||||
|
||||
type Props = {
|
||||
organizationId: string;
|
||||
user: UserWithOrganizationRoles;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function EditOrganizationRolesModal({ organizationId, user, isOpen, onClose }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [roles, setRoles] = useState<Array<Option<string>>>(
|
||||
user.organizationRoles.map(({ id, name }) => ({ value: id, title: name }))
|
||||
);
|
||||
const { data } = useSearchValues<OrganizationRole>('api/organization-roles', keyword);
|
||||
const name = user.name ?? decapitalize(t('organization_details.user'));
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const api = useApi();
|
||||
|
||||
const onSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await api.put(`api/organizations/${organizationId}/users/${user.id}/roles`, {
|
||||
json: {
|
||||
organizationRoleIds: roles.map(({ value }) => value),
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
className={modalStyles.content}
|
||||
overlayClassName={modalStyles.overlay}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<ModalLayout
|
||||
title={
|
||||
<>
|
||||
{t('organization_details.edit_organization_roles_of_user', {
|
||||
name,
|
||||
})}
|
||||
</>
|
||||
}
|
||||
subtitle={
|
||||
<>
|
||||
Authorize <b>{name}</b> to access the following roles
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<Button
|
||||
size="large"
|
||||
type="primary"
|
||||
title="general.save"
|
||||
isLoading={isLoading}
|
||||
onClick={onSubmit}
|
||||
/>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<FormField title="organizations.organization_role_other">
|
||||
<OrganizationRolesSelect
|
||||
value={roles}
|
||||
keyword={keyword}
|
||||
setKeyword={setKeyword}
|
||||
onChange={setRoles}
|
||||
/>
|
||||
</FormField>
|
||||
</ModalLayout>
|
||||
</ReactModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditOrganizationRolesModal;
|
|
@ -0,0 +1,14 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: _.unit(2);
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
gap: _.unit(2);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
155
packages/console/src/pages/OrganizationDetails/Members/index.tsx
Normal file
155
packages/console/src/pages/OrganizationDetails/Members/index.tsx
Normal file
|
@ -0,0 +1,155 @@
|
|||
import { type UserWithOrganizationRoles, type Organization } from '@logto/schemas';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import Plus from '@/assets/icons/plus.svg';
|
||||
import ActionsButton from '@/components/ActionsButton';
|
||||
import DateTime from '@/components/DateTime';
|
||||
import UserPreview from '@/components/ItemPreview/UserPreview';
|
||||
import Button from '@/ds-components/Button';
|
||||
import DangerousRaw from '@/ds-components/DangerousRaw';
|
||||
import Search from '@/ds-components/Search';
|
||||
import Table from '@/ds-components/Table';
|
||||
import Tag from '@/ds-components/Tag';
|
||||
import useActionTranslation from '@/hooks/use-action-translation';
|
||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
import { buildUrl } from '@/utils/url';
|
||||
|
||||
import EditOrganizationRolesModal from './EditOrganizationRolesModal';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
organization: Organization;
|
||||
};
|
||||
|
||||
function Members({ organization }: Props) {
|
||||
const api = useApi();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const { data, error, mutate } = useSWR<UserWithOrganizationRoles[], RequestError>(
|
||||
buildUrl(`api/organizations/${organization.id}/users`, { q: keyword })
|
||||
);
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const tAction = useActionTranslation();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [userToBeEdited, setUserToBeEdited] = useState<UserWithOrganizationRoles>();
|
||||
|
||||
if (error) {
|
||||
return null; // TODO: error handling
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null; // TODO: loading
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
rowGroups={[{ key: 'data', data }]}
|
||||
columns={[
|
||||
{
|
||||
dataIndex: 'user',
|
||||
title: 'User',
|
||||
colSpan: 4,
|
||||
render: (user) => <UserPreview user={user} />,
|
||||
},
|
||||
{
|
||||
dataIndex: 'roles',
|
||||
title: 'Organization roles',
|
||||
colSpan: 6,
|
||||
render: ({ organizationRoles }) => {
|
||||
if (organizationRoles.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.roles}>
|
||||
{organizationRoles.map(({ id, name }) => (
|
||||
<Tag key={id} variant="cell">
|
||||
{name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'lastSignInAt',
|
||||
title: 'Last sign-in',
|
||||
colSpan: 5,
|
||||
render: ({ lastSignInAt }) => <DateTime>{lastSignInAt}</DateTime>,
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: null,
|
||||
colSpan: 1,
|
||||
render: (user) => (
|
||||
<ActionsButton
|
||||
deleteConfirmation="organization_details.remove_user_from_organization_description"
|
||||
fieldName="organization_details.user"
|
||||
textOverrides={{
|
||||
edit: 'organization_details.edit_organization_roles',
|
||||
delete: 'organization_details.remove_user_from_organization',
|
||||
deleteConfirmation: 'general.remove',
|
||||
}}
|
||||
onEdit={() => {
|
||||
setUserToBeEdited(user);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
await api.delete(`api/organizations/${organization.id}/users/${user.id}`);
|
||||
void mutate();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
rowIndexKey="id"
|
||||
filter={
|
||||
<div className={styles.filter}>
|
||||
<Search
|
||||
defaultValue={keyword}
|
||||
isClearable={Boolean(keyword)}
|
||||
placeholder={t('organization_details.search_user_placeholder')}
|
||||
onSearch={(value) => {
|
||||
setKeyword(value);
|
||||
}}
|
||||
onClearSearch={() => {
|
||||
setKeyword('');
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="large"
|
||||
title={<DangerousRaw>{tAction('add', 'organization_details.member')}</DangerousRaw>}
|
||||
type="primary"
|
||||
icon={<Plus />}
|
||||
onClick={() => {
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{userToBeEdited && (
|
||||
<EditOrganizationRolesModal
|
||||
isOpen
|
||||
organizationId={organization.id}
|
||||
user={userToBeEdited}
|
||||
onClose={() => {
|
||||
setUserToBeEdited(undefined);
|
||||
void mutate();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* <AddMembersToOrganization
|
||||
organization={organization}
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
void mutate();
|
||||
}}
|
||||
/> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Members;
|
|
@ -0,0 +1,80 @@
|
|||
import { type Organization } from '@logto/schemas';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import DetailsForm from '@/components/DetailsForm';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import TextInput from '@/ds-components/TextInput';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Whether the organization is being deleted, this is used to disable the unsaved
|
||||
* changes alert modal.
|
||||
*/
|
||||
isDeleting: boolean;
|
||||
data: Organization;
|
||||
onUpdated: (data: Organization) => void;
|
||||
};
|
||||
|
||||
function Settings({ isDeleting, data, onUpdated }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { isDirty, isSubmitting },
|
||||
} = useForm<Partial<Organization>>({
|
||||
defaultValues: data,
|
||||
});
|
||||
const api = useApi();
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
trySubmitSafe(async (json) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedData = await api
|
||||
.patch(`api/organizations/${data.id}`, { json })
|
||||
.json<Organization>();
|
||||
reset(updatedData);
|
||||
toast.success(t('general.saved'));
|
||||
onUpdated(updatedData);
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailsForm
|
||||
isDirty={isDirty}
|
||||
isSubmitting={isSubmitting}
|
||||
onDiscard={reset}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<FormCard
|
||||
title="general.settings_nav"
|
||||
description="organization_details.settings_description"
|
||||
>
|
||||
<FormField title="general.name">
|
||||
<TextInput
|
||||
placeholder={t('organization_details.name_placeholder')}
|
||||
{...register('name')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="general.description">
|
||||
<TextInput
|
||||
placeholder={t('organization_details.description_placeholder')}
|
||||
{...register('description')}
|
||||
/>
|
||||
</FormField>
|
||||
</FormCard>
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
|
||||
</DetailsForm>
|
||||
);
|
||||
}
|
||||
|
||||
export default Settings;
|
|
@ -0,0 +1,29 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: _.unit(6);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: _.unit(1);
|
||||
}
|
||||
|
||||
.name {
|
||||
font: var(--font-title-1);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.label {
|
||||
font: var(--font-label-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
109
packages/console/src/pages/OrganizationDetails/index.tsx
Normal file
109
packages/console/src/pages/OrganizationDetails/index.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
import { type Organization } from '@logto/schemas';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import OrganizationIcon from '@/assets/icons/organization-preview.svg';
|
||||
import ActionsButton from '@/components/ActionsButton';
|
||||
import DetailsPage from '@/components/DetailsPage';
|
||||
import PageMeta from '@/components/PageMeta';
|
||||
import ThemedIcon from '@/components/ThemedIcon';
|
||||
import Card from '@/ds-components/Card';
|
||||
import CopyToClipboard from '@/ds-components/CopyToClipboard';
|
||||
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
|
||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||
|
||||
import Members from './Members';
|
||||
import Settings from './Settings';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const pathname = '/organizations';
|
||||
const tabs = Object.freeze({
|
||||
settings: 'settings',
|
||||
members: 'members',
|
||||
});
|
||||
|
||||
function OrganizationDetails() {
|
||||
const { id } = useParams();
|
||||
const { navigate } = useTenantPathname();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { data, error, mutate } = useSWR<Organization, RequestError>(
|
||||
id && `api/organizations/${id}`
|
||||
);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const api = useApi();
|
||||
|
||||
const deleteOrganization = useCallback(async () => {
|
||||
if (!id || isDeleting) {
|
||||
return;
|
||||
}
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
await api.delete(`api/organizations/${id}`);
|
||||
navigate(pathname);
|
||||
} catch (error) {
|
||||
toast.error(String(error));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [api, id, isDeleting, navigate]);
|
||||
|
||||
if (!id || error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DetailsPage backLink={pathname} backLinkTitle="organizations.title">
|
||||
<PageMeta titleKey="organization_details.page_title" />
|
||||
<Card className={styles.header}>
|
||||
<div className={styles.metadata}>
|
||||
<ThemedIcon for={OrganizationIcon} size={60} />
|
||||
<div>
|
||||
<div className={styles.name}>{data.name}</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>{t('organization_details.organization_id')} </span>
|
||||
<CopyToClipboard size="default" value={data.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ActionsButton
|
||||
buttonProps={{
|
||||
type: 'default',
|
||||
size: 'large',
|
||||
}}
|
||||
deleteConfirmation="organization_details.delete_confirmation"
|
||||
fieldName="organizations.title"
|
||||
onDelete={deleteOrganization}
|
||||
/>
|
||||
</Card>
|
||||
<TabNav>
|
||||
<TabNavItem href={`${pathname}/${id}/${tabs.settings}`}>Settings</TabNavItem>
|
||||
<TabNavItem href={`${pathname}/${id}/${tabs.members}`}>Members</TabNavItem>
|
||||
</TabNav>
|
||||
<Routes>
|
||||
<Route index element={<Navigate replace to={tabs.settings} />} />
|
||||
<Route
|
||||
path={tabs.settings}
|
||||
element={
|
||||
<Settings
|
||||
isDeleting={isDeleting}
|
||||
data={data}
|
||||
onUpdated={async (data) => mutate(data)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path={tabs.members} element={<Members organization={data} />} />
|
||||
</Routes>
|
||||
</DetailsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrganizationDetails;
|
Loading…
Reference in a new issue