mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
feat(console): init organization table
This commit is contained in:
parent
fbe9f7e89a
commit
23dc01c091
25 changed files with 419 additions and 95 deletions
14
packages/console/src/assets/icons/organization-preview.svg
Normal file
14
packages/console/src/assets/icons/organization-preview.svg
Normal file
|
@ -0,0 +1,14 @@
|
|||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" rx="6" fill="currentColor" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M18.4 12.1V16.3H22.6V12.1H18.4ZM17.35 10C16.7701 10 16.3 10.4701 16.3 11.05V17.35C16.3 17.9299 16.7701 18.4 17.35 18.4H23.65C24.2299 18.4 24.7 17.9299 24.7 17.35V11.05C24.7 10.4701 24.2299 10 23.65 10H17.35ZM12.1 24.7V28.9H16.3V24.7H12.1ZM11.05 22.6C10.4701 22.6 10 23.0701 10 23.65V29.95C10 30.5299 10.4701 31 11.05 31H17.35C17.9299 31 18.4 30.5299 18.4 29.95V23.65C18.4 23.0701 17.9299 22.6 17.35 22.6L15.25 22.6V21.55H25.75V22.6L23.65 22.6C23.0701 22.6 22.6 23.0701 22.6 23.65V29.95C22.6 30.5299 23.0701 31 23.65 31H29.95C30.5299 31 31 30.5299 31 29.95V23.65C31 23.0701 30.5299 22.6 29.95 22.6L27.85 22.6V21.55C27.85 20.3902 26.9098 19.45 25.75 19.45H21.5501V18.4004H19.4501L19.4501 19.45H15.25C14.0902 19.45 13.15 20.3902 13.15 21.55V22.6L11.05 22.6ZM24.7 28.9V24.7H28.9V28.9H24.7Z"
|
||||
fill="url(#paint0_linear_550_31630)" />
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_550_31630" x1="13.6094" y1="28.1562" x2="27.7188" y2="13.0625"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5D34F2" />
|
||||
<stop offset="1" stop-color="#FF88FA" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
@ -6,6 +6,7 @@ import Delete from '@/assets/icons/delete.svg';
|
|||
import Edit from '@/assets/icons/edit.svg';
|
||||
import More from '@/assets/icons/more.svg';
|
||||
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
|
||||
import type { Props as ButtonProps } from '@/ds-components/Button';
|
||||
import ConfirmModal from '@/ds-components/ConfirmModal';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import useActionTranslation from '@/hooks/use-action-translation';
|
||||
|
@ -13,23 +14,47 @@ import useActionTranslation from '@/hooks/use-action-translation';
|
|||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Props that will be passed to the button that opens the menu. It will override the
|
||||
* default props.
|
||||
*/
|
||||
buttonProps?: Partial<ButtonProps>;
|
||||
/** A function that will be called when the user confirms the deletion. */
|
||||
onDelete: () => void | Promise<void>;
|
||||
/** A function that will be called when the user clicks the edit button. */
|
||||
onEdit: () => void | Promise<void>;
|
||||
/**
|
||||
* A function that will be called when the user clicks the edit button. If not provided,
|
||||
* the edit button will not be displayed.
|
||||
*/
|
||||
onEdit?: () => void | Promise<void>;
|
||||
/** The translation key of the content that will be displayed in the confirmation modal. */
|
||||
deleteConfirmation: AdminConsoleKey;
|
||||
/** The name of the field that is being operated. */
|
||||
fieldName: AdminConsoleKey;
|
||||
/** Overrides the default translations of the edit and delete buttons. */
|
||||
textOverrides?: {
|
||||
/** The translation key of the edit button. */
|
||||
edit?: AdminConsoleKey;
|
||||
/** The translation key of the delete button. */
|
||||
delete?: AdminConsoleKey;
|
||||
/** The translation key of the confirmation modal primary button. */
|
||||
deleteConfirmation?: AdminConsoleKey;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A button that displays a three-dot icon and opens a menu the following options:
|
||||
*
|
||||
* - Edit
|
||||
* - Edit (optional)
|
||||
* - Delete
|
||||
*/
|
||||
function ActionsButton({ onDelete, onEdit, deleteConfirmation, fieldName }: Props) {
|
||||
function ActionsButton({
|
||||
buttonProps,
|
||||
onDelete,
|
||||
onEdit,
|
||||
deleteConfirmation,
|
||||
fieldName,
|
||||
textOverrides,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const tAction = useActionTranslation();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
@ -48,12 +73,23 @@ function ActionsButton({ onDelete, onEdit, deleteConfirmation, fieldName }: Prop
|
|||
return (
|
||||
<>
|
||||
<ActionMenu
|
||||
buttonProps={{ icon: <More className={styles.moreIcon} />, size: 'small', type: 'text' }}
|
||||
buttonProps={{
|
||||
icon: <More className={styles.moreIcon} />,
|
||||
size: 'small',
|
||||
type: 'text',
|
||||
...buttonProps,
|
||||
}}
|
||||
title={t('general.more_options')}
|
||||
>
|
||||
<ActionMenuItem iconClassName={styles.moreIcon} icon={<Edit />} onClick={onEdit}>
|
||||
{tAction('edit', fieldName)}
|
||||
</ActionMenuItem>
|
||||
{onEdit && (
|
||||
<ActionMenuItem iconClassName={styles.moreIcon} icon={<Edit />} onClick={onEdit}>
|
||||
{textOverrides?.edit ? (
|
||||
<DynamicT forKey={textOverrides.edit} />
|
||||
) : (
|
||||
tAction('edit', fieldName)
|
||||
)}
|
||||
</ActionMenuItem>
|
||||
)}
|
||||
<ActionMenuItem
|
||||
icon={<Delete />}
|
||||
type="danger"
|
||||
|
@ -61,12 +97,16 @@ function ActionsButton({ onDelete, onEdit, deleteConfirmation, fieldName }: Prop
|
|||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{tAction('delete', fieldName)}
|
||||
{textOverrides?.delete ? (
|
||||
<DynamicT forKey={textOverrides.delete} />
|
||||
) : (
|
||||
tAction('delete', fieldName)
|
||||
)}
|
||||
</ActionMenuItem>
|
||||
</ActionMenu>
|
||||
<ConfirmModal
|
||||
isOpen={isModalOpen}
|
||||
confirmButtonText="general.delete"
|
||||
confirmButtonText={textOverrides?.deleteConfirmation ?? 'general.delete'}
|
||||
isLoading={isDeleting}
|
||||
onCancel={() => {
|
||||
setIsModalOpen(false);
|
|
@ -1,5 +1,6 @@
|
|||
import { Theme } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import EmptyDark from '@/assets/images/table-empty-dark.svg';
|
||||
|
@ -9,7 +10,7 @@ import useTheme from '@/hooks/use-theme';
|
|||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
title?: ReactNode;
|
||||
size?: 'large' | 'medium' | 'small';
|
||||
className?: string;
|
||||
};
|
||||
|
|
|
@ -10,4 +10,5 @@
|
|||
.description {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
|
28
packages/console/src/components/ItemPreview/UserPreview.tsx
Normal file
28
packages/console/src/components/ItemPreview/UserPreview.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { type User } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
|
||||
import SuspendedTag from '@/pages/Users/components/SuspendedTag';
|
||||
import { getUserTitle, getUserSubtitle } from '@/utils/user';
|
||||
|
||||
import UserAvatar from '../UserAvatar';
|
||||
|
||||
import ItemPreview from '.';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
/** A component that renders a preview of a user. It's useful for displaying a user in a list. */
|
||||
function UserPreview({ user }: Props) {
|
||||
return (
|
||||
<ItemPreview
|
||||
title={getUserTitle(user)}
|
||||
subtitle={getUserSubtitle(user)}
|
||||
icon={<UserAvatar size="large" user={user} />}
|
||||
to={`/users/${user.id}`}
|
||||
suffix={conditional(user.isSuspended && <SuspendedTag />)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserPreview;
|
|
@ -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 OrganizationScopesSelect({ value, onChange, keyword, setKeyword }: Props) {
|
||||
const { data: scopes } = useSearchValues<OrganizationScope>('api/organization-scopes', 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 OrganizationScopesSelect;
|
|
@ -0,0 +1,3 @@
|
|||
.icon {
|
||||
color: var(--color-specific-icon-bg);
|
||||
}
|
16
packages/console/src/components/ThemedIcon/index.tsx
Normal file
16
packages/console/src/components/ThemedIcon/index.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
for: SvgComponent;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders an icon with color according to the current theme. It uses `--color-specific-icon-bg`
|
||||
* CSS variable to determine the color.
|
||||
*/
|
||||
function ThemedIcon({ for: Icon, size }: Props) {
|
||||
return <Icon className={styles.icon} style={{ width: size, height: size }} />;
|
||||
}
|
||||
|
||||
export default ThemedIcon;
|
|
@ -153,7 +153,8 @@ function ConsoleContent() {
|
|||
{isDevFeaturesEnabled && (
|
||||
<Route path="organizations">
|
||||
<Route index element={<Organizations />} />
|
||||
<Route path=":tab" element={<Organizations />} />
|
||||
<Route path="create" element={<Organizations />} />
|
||||
<Route path="settings" element={<Organizations tab="settings" />} />
|
||||
</Route>
|
||||
)}
|
||||
<Route path="profile">
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import type { AdminConsoleKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import ReactModal from 'react-modal';
|
||||
|
||||
import type { ButtonType } from '@/ds-components/Button';
|
||||
import Button from '@/ds-components/Button';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
import type DangerousRaw from '../DangerousRaw';
|
||||
import ModalLayout from '../ModalLayout';
|
||||
import type { Props as ModalLayoutProps } from '../ModalLayout';
|
||||
|
||||
|
@ -15,10 +16,10 @@ import * as styles from './index.module.scss';
|
|||
export type ConfirmModalProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
title?: AdminConsoleKey;
|
||||
title?: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
|
||||
confirmButtonType?: ButtonType;
|
||||
confirmButtonText?: AdminConsoleKey;
|
||||
cancelButtonText?: AdminConsoleKey;
|
||||
confirmButtonText?: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
|
||||
cancelButtonText?: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
|
||||
isOpen: boolean;
|
||||
isConfirmButtonDisabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
|
|
|
@ -4,6 +4,12 @@ type Props = {
|
|||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The component to render a raw component when a single translate key is not enough.
|
||||
*
|
||||
* It is not dangerous for rendering, but it may cause unexpected behavior if the content
|
||||
* is intended to be translated.
|
||||
*/
|
||||
function DangerousRaw({ children }: Props) {
|
||||
return <span>{children}</span>;
|
||||
}
|
||||
|
|
|
@ -164,7 +164,8 @@ function Table<
|
|||
</OverlayScrollbar>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
{footer}
|
||||
{/* Fall back to a div if footer is not provided to avoid layout shift. */}
|
||||
{footer ?? <div />}
|
||||
{pagination && <Pagination className={styles.pagination} {...pagination} />}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,7 @@ import { type AdminConsoleKey } from '@logto/phrases';
|
|||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const decapitalize = (value: string) => value.charAt(0).toLowerCase() + value.slice(1);
|
||||
import { decapitalize } from '@/utils/string';
|
||||
|
||||
/**
|
||||
* Returns a function that translates a given action and target into a short phrase.
|
||||
|
@ -11,7 +11,7 @@ const useActionTranslation = () => {
|
|||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
return useCallback(
|
||||
(action: 'edit' | 'create' | 'delete', target: AdminConsoleKey) =>
|
||||
(action: 'edit' | 'create' | 'delete' | 'add', target: AdminConsoleKey) =>
|
||||
t(`general.${action}_field`, { field: decapitalize(String(t(target))) }),
|
||||
[t]
|
||||
);
|
||||
|
|
34
packages/console/src/hooks/use-search-values.ts
Normal file
34
packages/console/src/hooks/use-search-values.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { useMemo } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { defaultPageSize } from '@/consts';
|
||||
import { buildUrl } from '@/utils/url';
|
||||
|
||||
import { type RequestError } from './use-api';
|
||||
|
||||
const useSearchValues = <T>(pathname: string, keyword: string) => {
|
||||
const {
|
||||
data: response,
|
||||
error, // TODO: handle error
|
||||
mutate,
|
||||
} = useSWR<[T[], number], RequestError>(
|
||||
buildUrl(pathname, {
|
||||
page: String(1),
|
||||
page_size: String(defaultPageSize),
|
||||
q: keyword,
|
||||
}),
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
const [data] = response ?? [[], 0];
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
data,
|
||||
mutate,
|
||||
error,
|
||||
}),
|
||||
[data, error, mutate]
|
||||
);
|
||||
};
|
||||
|
||||
export default useSearchValues;
|
|
@ -0,0 +1,90 @@
|
|||
import { type Organization, type CreateOrganization } from '@logto/schemas';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
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 * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: (createdId?: string) => void;
|
||||
};
|
||||
|
||||
function CreateOrganizationModal({ isOpen, onClose }: Props) {
|
||||
const api = useApi();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const {
|
||||
reset,
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<Partial<CreateOrganization>>();
|
||||
const submit = handleSubmit(
|
||||
trySubmitSafe(async (json) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { id } = await api
|
||||
.post('api/organizations', {
|
||||
json,
|
||||
})
|
||||
.json<Organization>();
|
||||
onClose(id);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Reset form on open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
reset({});
|
||||
}
|
||||
}, [isOpen, reset]);
|
||||
|
||||
return (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
className={modalStyles.content}
|
||||
overlayClassName={modalStyles.overlay}
|
||||
onRequestClose={() => {
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<ModalLayout
|
||||
title="organizations.create_organization"
|
||||
footer={
|
||||
<Button type="primary" title="general.create" isLoading={isLoading} onClick={submit} />
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<FormField isRequired title="general.name">
|
||||
<TextInput
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
placeholder={t('organizations.organization_name_placeholder')}
|
||||
error={Boolean(errors.name)}
|
||||
{...register('name', { required: true })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="general.description">
|
||||
<TextInput
|
||||
error={Boolean(errors.description)}
|
||||
placeholder={t('organizations.organization_description_placeholder')}
|
||||
{...register('description')}
|
||||
/>
|
||||
</FormField>
|
||||
</ModalLayout>
|
||||
</ReactModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateOrganizationModal;
|
|
@ -0,0 +1,71 @@
|
|||
import { type Organization } from '@logto/schemas';
|
||||
import { joinPath } from '@silverhand/essentials';
|
||||
import { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import OrganizationIcon from '@/assets/icons/organization-preview.svg';
|
||||
import ItemPreview from '@/components/ItemPreview';
|
||||
import ThemedIcon from '@/components/ThemedIcon';
|
||||
import { defaultPageSize } from '@/consts';
|
||||
import CopyToClipboard from '@/ds-components/CopyToClipboard';
|
||||
import Table from '@/ds-components/Table';
|
||||
import { type RequestError } from '@/hooks/use-api';
|
||||
import { buildUrl } from '@/utils/url';
|
||||
|
||||
const pageSize = defaultPageSize;
|
||||
const pathname = '/organizations';
|
||||
const apiPathname = 'api/organizations';
|
||||
|
||||
function OrganizationsTable() {
|
||||
const [page, setPage] = useState(1);
|
||||
const { data: response, error } = useSWR<[Organization[], number], RequestError>(
|
||||
buildUrl(apiPathname, {
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
})
|
||||
);
|
||||
const isLoading = !response && !error;
|
||||
const [data, totalCount] = response ?? [[], 0];
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>; // TODO: Add loading skeleton
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
rowGroups={[{ key: 'data', data }]}
|
||||
columns={[
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
render: ({ name, id }) => (
|
||||
<ItemPreview
|
||||
title={name}
|
||||
icon={<ThemedIcon for={OrganizationIcon} />}
|
||||
to={joinPath(pathname, id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Organization ID',
|
||||
dataIndex: 'id',
|
||||
render: ({ id }) => <CopyToClipboard value={id} variant="text" />,
|
||||
},
|
||||
{
|
||||
title: 'Members',
|
||||
dataIndex: 'members',
|
||||
render: () => 'members',
|
||||
},
|
||||
]}
|
||||
rowIndexKey="id"
|
||||
pagination={{
|
||||
page,
|
||||
totalCount,
|
||||
pageSize,
|
||||
onChange: setPage,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrganizationsTable;
|
|
@ -19,11 +19,11 @@ const organizationScopesPath = 'api/organization-scopes';
|
|||
type Props = {
|
||||
isOpen: boolean;
|
||||
editData: Nullable<OrganizationScope>;
|
||||
onFinish: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/** A modal that allows users to create or edit an organization scope. */
|
||||
function PermissionModal({ isOpen, editData, onFinish }: Props) {
|
||||
function PermissionModal({ isOpen, editData, onClose }: Props) {
|
||||
const api = useApi();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const {
|
||||
|
@ -49,7 +49,7 @@ function PermissionModal({ isOpen, editData, onFinish }: Props) {
|
|||
: api.post(organizationScopesPath, {
|
||||
json,
|
||||
}));
|
||||
onFinish();
|
||||
onClose();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
@ -67,7 +67,7 @@ function PermissionModal({ isOpen, editData, onFinish }: Props) {
|
|||
isOpen={isOpen}
|
||||
className={modalStyles.content}
|
||||
overlayClassName={modalStyles.overlay}
|
||||
onRequestClose={onFinish}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<ModalLayout
|
||||
title={<DangerousRaw>{title}</DangerousRaw>}
|
||||
|
@ -79,7 +79,7 @@ function PermissionModal({ isOpen, editData, onFinish }: Props) {
|
|||
onClick={submit}
|
||||
/>
|
||||
}
|
||||
onClose={onFinish}
|
||||
onClose={onClose}
|
||||
>
|
||||
<FormField isRequired title="general.name">
|
||||
<TextInput
|
||||
|
|
|
@ -4,9 +4,9 @@ import { useState } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import ActionsButton from '@/components/ActionsButton';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
import ActionsButton from '@/pages/Organizations/ActionsButton';
|
||||
import { buildUrl } from '@/utils/url';
|
||||
|
||||
import PermissionModal from '../PermissionModal';
|
||||
|
@ -46,7 +46,7 @@ function PermissionsField() {
|
|||
<PermissionModal
|
||||
isOpen={isModalOpen}
|
||||
editData={editData}
|
||||
onFinish={() => {
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
void mutate();
|
||||
}}
|
||||
|
|
|
@ -1,37 +1,31 @@
|
|||
import {
|
||||
type OrganizationRole,
|
||||
type OrganizationRoleWithScopes,
|
||||
type OrganizationScope,
|
||||
} from '@logto/schemas';
|
||||
import { type OrganizationRole, type OrganizationRoleWithScopes } from '@logto/schemas';
|
||||
import { type Nullable } from '@silverhand/essentials';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactModal from 'react-modal';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { defaultPageSize } from '@/consts';
|
||||
import OrganizationScopesSelect from '@/components/OrganizationScopesSelect';
|
||||
import Button from '@/ds-components/Button';
|
||||
import DangerousRaw from '@/ds-components/DangerousRaw';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import ModalLayout from '@/ds-components/ModalLayout';
|
||||
import MultiSelect, { type Option } from '@/ds-components/Select/MultiSelect';
|
||||
import { type Option } from '@/ds-components/Select/MultiSelect';
|
||||
import TextInput from '@/ds-components/TextInput';
|
||||
import useActionTranslation from '@/hooks/use-action-translation';
|
||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { buildUrl } from '@/utils/url';
|
||||
|
||||
const organizationRolePath = 'api/organization-roles';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
editData: Nullable<OrganizationRoleWithScopes>;
|
||||
onFinish: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/** A modal that allows users to create or edit an organization role. */
|
||||
function RoleModal({ isOpen, editData, onFinish }: Props) {
|
||||
function RoleModal({ isOpen, editData, onClose }: Props) {
|
||||
const api = useApi();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const {
|
||||
|
@ -43,26 +37,13 @@ function RoleModal({ isOpen, editData, onFinish }: Props) {
|
|||
} = useForm<Partial<OrganizationRole> & { scopes: Array<Option<string>> }>({
|
||||
defaultValues: { scopes: [] },
|
||||
});
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const {
|
||||
data: response,
|
||||
error, // TODO: handle error
|
||||
mutate,
|
||||
} = useSWR<[OrganizationScope[], number], RequestError>(
|
||||
buildUrl('api/organization-scopes', {
|
||||
page: String(1),
|
||||
page_size: String(defaultPageSize),
|
||||
q: keyword,
|
||||
}),
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
const [scopes] = response ?? [[], 0];
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const tAction = useActionTranslation();
|
||||
const title = editData
|
||||
? tAction('edit', 'organizations.organization_role')
|
||||
: tAction('create', 'organizations.organization_role');
|
||||
const action = editData ? t('general.save') : tAction('create', 'organizations.role');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
const submit = handleSubmit(async ({ scopes, ...json }) => {
|
||||
setIsLoading(true);
|
||||
|
@ -81,7 +62,7 @@ function RoleModal({ isOpen, editData, onFinish }: Props) {
|
|||
await api.put(`${organizationRolePath}/${id}/scopes`, {
|
||||
json: { organizationScopeIds: scopes.map(({ value }) => value) },
|
||||
});
|
||||
onFinish();
|
||||
onClose();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
@ -102,19 +83,12 @@ function RoleModal({ isOpen, editData, onFinish }: Props) {
|
|||
}
|
||||
}, [editData, isOpen, reset]);
|
||||
|
||||
// Initial fetch on open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void mutate();
|
||||
}
|
||||
}, [isOpen, mutate]);
|
||||
|
||||
return (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
className={modalStyles.content}
|
||||
overlayClassName={modalStyles.overlay}
|
||||
onRequestClose={onFinish}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<ModalLayout
|
||||
title={<DangerousRaw>{title}</DangerousRaw>}
|
||||
|
@ -126,7 +100,7 @@ function RoleModal({ isOpen, editData, onFinish }: Props) {
|
|||
onClick={submit}
|
||||
/>
|
||||
}
|
||||
onClose={onFinish}
|
||||
onClose={onClose}
|
||||
>
|
||||
<FormField isRequired title="general.name">
|
||||
<TextInput
|
||||
|
@ -149,12 +123,11 @@ function RoleModal({ isOpen, editData, onFinish }: Props) {
|
|||
name="scopes"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<MultiSelect
|
||||
<OrganizationScopesSelect
|
||||
keyword={keyword}
|
||||
setKeyword={setKeyword}
|
||||
value={value}
|
||||
options={scopes.map(({ id, name }) => ({ value: id, title: name }))}
|
||||
placeholder="organizations.search_permission_placeholder"
|
||||
onChange={onChange}
|
||||
onSearch={setKeyword}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -163,4 +136,5 @@ function RoleModal({ isOpen, editData, onFinish }: Props) {
|
|||
</ReactModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleModal;
|
||||
|
|
|
@ -4,10 +4,10 @@ import { useState } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import ActionsButton from '@/components/ActionsButton';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import Tag from '@/ds-components/Tag';
|
||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
import ActionsButton from '@/pages/Organizations/ActionsButton';
|
||||
import { buildUrl } from '@/utils/url';
|
||||
|
||||
import RoleModal from '../RoleModal';
|
||||
|
@ -49,7 +49,7 @@ function RolesField() {
|
|||
<RoleModal
|
||||
isOpen={isModalOpen}
|
||||
editData={editData}
|
||||
onFinish={() => {
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
void mutate();
|
||||
}}
|
||||
|
|
|
@ -1,38 +1,66 @@
|
|||
import { joinPath } from '@silverhand/essentials';
|
||||
import { condString, joinPath } from '@silverhand/essentials';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import Plus from '@/assets/icons/plus.svg';
|
||||
import PageMeta from '@/components/PageMeta';
|
||||
import Button from '@/ds-components/Button';
|
||||
import CardTitle from '@/ds-components/CardTitle';
|
||||
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
|
||||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||
import * as pageLayout from '@/scss/page-layout.module.scss';
|
||||
|
||||
import CreateOrganizationModal from './CreateOrganizationModal';
|
||||
import OrganizationsTable from './OrganizationsTable';
|
||||
import Settings from './Settings';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const pathnames = Object.freeze({
|
||||
organizations: 'organizations',
|
||||
const organizationsPathname = '/organizations';
|
||||
const createPathname = `${organizationsPathname}/create`;
|
||||
|
||||
const tabs = Object.freeze({
|
||||
settings: 'settings',
|
||||
});
|
||||
|
||||
function Organizations() {
|
||||
type Props = {
|
||||
tab?: keyof typeof tabs;
|
||||
};
|
||||
|
||||
function Organizations({ tab }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { tab } = useParams();
|
||||
const { navigate, match } = useTenantPathname();
|
||||
const isCreating = match(createPathname);
|
||||
|
||||
return (
|
||||
<div className={pageLayout.container}>
|
||||
<CreateOrganizationModal
|
||||
isOpen={isCreating}
|
||||
onClose={(createdId?: string) => {
|
||||
navigate(organizationsPathname + condString(createdId && `/${createdId}`));
|
||||
}}
|
||||
/>
|
||||
<PageMeta titleKey="organizations.page_title" />
|
||||
<div className={pageLayout.headline}>
|
||||
<CardTitle title="organizations.title" subtitle="organizations.subtitle" />
|
||||
<Button
|
||||
icon={<Plus />}
|
||||
type="primary"
|
||||
size="large"
|
||||
title="organizations.create_organization"
|
||||
onClick={() => {
|
||||
navigate(createPathname);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TabNav className={styles.tabs}>
|
||||
<TabNavItem href={joinPath('..', pathnames.organizations)} isActive={!tab}>
|
||||
<TabNavItem href="/organizations" isActive={!tab}>
|
||||
{t('organizations.title')}
|
||||
</TabNavItem>
|
||||
<TabNavItem href={pathnames.settings} isActive={tab === pathnames.settings}>
|
||||
<TabNavItem href={joinPath('/organizations', tabs.settings)} isActive={tab === 'settings'}>
|
||||
{t('general.settings_nav')}
|
||||
</TabNavItem>
|
||||
</TabNav>
|
||||
{!tab && <>Not found</>}
|
||||
{tab === pathnames.settings && <Settings />}
|
||||
{!tab && <OrganizationsTable />}
|
||||
{tab === 'settings' && <Settings />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -11,8 +11,7 @@ import Plus from '@/assets/icons/plus.svg';
|
|||
import ApplicationName from '@/components/ApplicationName';
|
||||
import DateTime from '@/components/DateTime';
|
||||
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
|
||||
import ItemPreview from '@/components/ItemPreview';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import UserPreview from '@/components/ItemPreview/UserPreview';
|
||||
import { defaultPageSize } from '@/consts';
|
||||
import Button from '@/ds-components/Button';
|
||||
import ConfirmModal from '@/ds-components/ConfirmModal';
|
||||
|
@ -24,9 +23,7 @@ import type { RequestError } from '@/hooks/use-api';
|
|||
import useApi from '@/hooks/use-api';
|
||||
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
|
||||
import AssignRoleModal from '@/pages/Roles/components/AssignRoleModal';
|
||||
import SuspendedTag from '@/pages/Users/components/SuspendedTag';
|
||||
import { buildUrl, formatSearchKeyword } from '@/utils/url';
|
||||
import { getUserTitle, getUserSubtitle } from '@/utils/user';
|
||||
|
||||
import type { RoleDetailsOutletContext } from '../types';
|
||||
|
||||
|
@ -93,19 +90,7 @@ function RoleUsers() {
|
|||
title: t('role_details.users.name_column'),
|
||||
dataIndex: 'name',
|
||||
colSpan: 5,
|
||||
render: (user) => {
|
||||
const { id, isSuspended } = user;
|
||||
|
||||
return (
|
||||
<ItemPreview
|
||||
title={getUserTitle(user)}
|
||||
subtitle={getUserSubtitle(user)}
|
||||
icon={<UserAvatar size="large" user={user} />}
|
||||
to={`/users/${id}`}
|
||||
suffix={conditional(isSuspended && <SuspendedTag />)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
render: (user) => <UserPreview user={user} />,
|
||||
},
|
||||
{
|
||||
title: t('role_details.users.app_column'),
|
||||
|
|
1
packages/console/src/utils/string.ts
Normal file
1
packages/console/src/utils/string.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const decapitalize = (value: string) => value.charAt(0).toLowerCase() + value.slice(1);
|
|
@ -158,6 +158,7 @@
|
|||
--color-env-tag-development: rgba(93, 52, 242, 15%);
|
||||
--color-env-tag-staging: rgba(255, 185, 90, 35%);
|
||||
--color-env-tag-production: rgba(131, 218, 133, 35%);
|
||||
--color-specific-icon-bg: #f3effa;
|
||||
|
||||
// Shadows
|
||||
--shadow-1: 0 4px 8px rgba(0, 0, 0, 8%);
|
||||
|
@ -356,6 +357,7 @@
|
|||
--color-env-tag-development: rgba(202, 190, 255, 32%);
|
||||
--color-env-tag-staging: rgba(235, 153, 24, 36%);
|
||||
--color-env-tag-production: rgba(104, 190, 108, 36%);
|
||||
--color-specific-icon-bg: rgba(247, 248, 248, 12%);
|
||||
|
||||
// Shadows
|
||||
--shadow-1: 0 4px 8px rgba(0, 0, 0, 8%);
|
||||
|
|
Loading…
Reference in a new issue