0
Fork 0
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:
Gao Sun 2023-10-23 14:58:28 +08:00
parent 23dc01c091
commit 0db5e9f1ce
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
8 changed files with 508 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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