mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
Merge pull request #4683 from logto-io/gao-console-org-2
feat(console): init organization settings
This commit is contained in:
commit
70efc1b2c0
25 changed files with 525 additions and 29 deletions
7
packages/console/src/assets/icons/organization.svg
Normal file
7
packages/console/src/assets/icons/organization.svg
Normal file
|
@ -0,0 +1,7 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M8.33341 3.33317V6.6665H11.6667V3.33317H8.33341ZM7.50008 1.6665C7.03984 1.6665 6.66675 2.0396 6.66675 2.49984V7.49984C6.66675 7.96007 7.03984 8.33317 7.50008 8.33317H9.16675V9.1665H5.83342C4.91294 9.1665 4.16675 9.9127 4.16675 10.8332V11.6665H2.50008C2.03984 11.6665 1.66675 12.0396 1.66675 12.4998V17.4998C1.66675 17.9601 2.03984 18.3332 2.50008 18.3332H7.50008C7.96032 18.3332 8.33341 17.9601 8.33341 17.4998V12.4998C8.33341 12.0396 7.96032 11.6665 7.50008 11.6665H5.83342V10.8332H14.1667V11.6665H12.5001C12.0398 11.6665 11.6667 12.0396 11.6667 12.4998V17.4998C11.6667 17.9601 12.0398 18.3332 12.5001 18.3332H17.5001C17.9603 18.3332 18.3334 17.9601 18.3334 17.4998V12.4998C18.3334 12.0396 17.9603 11.6665 17.5001 11.6665H15.8334V10.8332C15.8334 9.9127 15.0872 9.1665 14.1667 9.1665H10.8334V8.33317H12.5001C12.9603 8.33317 13.3334 7.96007 13.3334 7.49984V2.49984C13.3334 2.0396 12.9603 1.6665 12.5001 1.6665H7.50008ZM3.33341 13.3332V16.6665H6.66675V13.3332H3.33341ZM13.3334 16.6665V13.3332H16.6667V16.6665H13.3334Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
60
packages/console/src/components/DeleteButton/index.tsx
Normal file
60
packages/console/src/components/DeleteButton/index.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { useCallback, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Delete from '@/assets/icons/delete.svg';
|
||||
import ConfirmModal from '@/ds-components/ConfirmModal';
|
||||
import IconButton from '@/ds-components/IconButton';
|
||||
import { Tooltip } from '@/ds-components/Tip';
|
||||
|
||||
type Props = {
|
||||
/** A function that will be called when the user confirms the deletion. */
|
||||
onDelete: () => void | Promise<void>;
|
||||
/** The text or content to display in the confirmation modal. */
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A button that displays a trash can icon, with a tooltip that says localized
|
||||
* "Delete". Clicking the button will pop up a confirmation modal.
|
||||
*/
|
||||
function DeleteButton({ onDelete, content }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [onDelete]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip content={<div>{t('general.delete')}</div>}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ConfirmModal
|
||||
isOpen={isModalOpen}
|
||||
confirmButtonText="general.delete"
|
||||
isLoading={isDeleting}
|
||||
onCancel={() => {
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
{content}
|
||||
</ConfirmModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default DeleteButton;
|
|
@ -20,13 +20,7 @@
|
|||
}
|
||||
|
||||
.name {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
vertical-align: bottom;
|
||||
padding: _.unit(1) _.unit(2);
|
||||
border-radius: 6px;
|
||||
background: var(--color-neutral-95);
|
||||
@include _.text-ellipsis;
|
||||
@include _.tag;
|
||||
}
|
||||
|
||||
.description {
|
||||
|
|
|
@ -9,6 +9,7 @@ import Connection from '@/assets/icons/connection.svg';
|
|||
import Gear from '@/assets/icons/gear.svg';
|
||||
import Hook from '@/assets/icons/hook.svg';
|
||||
import List from '@/assets/icons/list.svg';
|
||||
import Organization from '@/assets/icons/organization.svg';
|
||||
import UserProfile from '@/assets/icons/profile.svg';
|
||||
import ResourceIcon from '@/assets/icons/resource.svg';
|
||||
import Role from '@/assets/icons/role.svg';
|
||||
|
@ -93,6 +94,11 @@ export const useSidebarMenuItems = (): {
|
|||
{
|
||||
title: 'users',
|
||||
items: [
|
||||
{
|
||||
Icon: Organization,
|
||||
title: 'organizations',
|
||||
isHidden: !isDevFeaturesEnabled,
|
||||
},
|
||||
{
|
||||
Icon: UserProfile,
|
||||
title: 'users',
|
||||
|
|
|
@ -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 Organizations from '@/pages/Organizations';
|
||||
import Profile from '@/pages/Profile';
|
||||
import ChangePasswordModal from '@/pages/Profile/containers/ChangePasswordModal';
|
||||
import LinkEmailModal from '@/pages/Profile/containers/LinkEmailModal';
|
||||
|
@ -149,6 +150,12 @@ function ConsoleContent() {
|
|||
<Route path={RoleDetailsTabs.M2mApps} element={<RoleApplications />} />
|
||||
</Route>
|
||||
</Route>
|
||||
{isDevFeaturesEnabled && (
|
||||
<Route path="organizations">
|
||||
<Route index element={<Organizations />} />
|
||||
<Route path=":tab" element={<Organizations />} />
|
||||
</Route>
|
||||
)}
|
||||
<Route path="profile">
|
||||
<Route index element={<Profile />} />
|
||||
<Route path="verify-password" element={<VerifyPasswordModal />} />
|
||||
|
|
|
@ -27,6 +27,9 @@ function DropdownItem({
|
|||
className={classNames(styles.item, styles[type], className)}
|
||||
role="menuitem"
|
||||
tabIndex={0}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onKeyDown={onKeyDownHandler(onClick)}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
|
|
@ -30,6 +30,8 @@ type Props = {
|
|||
titleClassName?: string;
|
||||
horizontalAlign?: HorizontalAlignment;
|
||||
hasOverflowContent?: boolean;
|
||||
/** Set to `true` to directly render the dropdown without the overlay. */
|
||||
noOverlay?: true;
|
||||
};
|
||||
|
||||
function Div({
|
||||
|
@ -50,6 +52,7 @@ function Dropdown({
|
|||
titleClassName,
|
||||
horizontalAlign = 'end',
|
||||
hasOverflowContent,
|
||||
noOverlay,
|
||||
}: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
@ -64,11 +67,15 @@ function Dropdown({
|
|||
const WrapperComponent = hasOverflowContent ? Div : OverlayScrollbar;
|
||||
|
||||
return (
|
||||
// Using `ReactModal` will cause accessibility issues for multi-select since the dropdown is
|
||||
// not a child or sibling of the input element. Thus the tab order will be broken. Consider
|
||||
// using something else instead.
|
||||
<ReactModal
|
||||
shouldCloseOnOverlayClick
|
||||
isOpen={isOpen}
|
||||
style={{
|
||||
content: {
|
||||
zIndex: 103,
|
||||
width:
|
||||
isFullWidth && anchorRef.current
|
||||
? anchorRef.current.getBoundingClientRect().width
|
||||
|
@ -77,8 +84,10 @@ function Dropdown({
|
|||
...position,
|
||||
},
|
||||
}}
|
||||
shouldFocusAfterRender={false}
|
||||
className={classNames(styles.content, positionState.verticalAlign === 'top' && styles.onTop)}
|
||||
overlayClassName={styles.overlay}
|
||||
overlayElement={noOverlay && ((_, contentElement) => contentElement)}
|
||||
onRequestClose={(event) => {
|
||||
/**
|
||||
* Note:
|
||||
|
|
|
@ -16,6 +16,40 @@
|
|||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&.multiple {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: _.unit(2);
|
||||
padding: _.unit(2) _.unit(3);
|
||||
cursor: text;
|
||||
|
||||
.tag {
|
||||
cursor: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: _.unit(1);
|
||||
}
|
||||
|
||||
.close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.delete {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: _.unit(-0.5);
|
||||
}
|
||||
|
||||
input {
|
||||
color: var(--color-text);
|
||||
font: var(--font-body-2);
|
||||
background: transparent;
|
||||
flex-grow: 1;
|
||||
padding: _.unit(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
@include _.text-ellipsis;
|
||||
}
|
||||
|
@ -84,3 +118,9 @@
|
|||
padding: _.unit(1);
|
||||
max-height: 288px;
|
||||
}
|
||||
|
||||
.noResult {
|
||||
color: var(--color-placeholder);
|
||||
font: var(--font-body-2);
|
||||
padding: _.unit(2);
|
||||
}
|
||||
|
|
|
@ -34,7 +34,9 @@ function TabNavItem<Paths extends string>({
|
|||
}: Props<Paths>) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { match, getTo } = useMatchTenantPath();
|
||||
const selected = href ? match(href) : isActive;
|
||||
// `isActive` is used to override the default behavior of `match` when the
|
||||
// tab is not a link or the link is a relative path.
|
||||
const selected = isActive ?? (href ? match(href) : false);
|
||||
|
||||
return (
|
||||
<div className={styles.item}>
|
||||
|
|
|
@ -165,6 +165,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
|
|
|
@ -37,6 +37,8 @@ export type Props<
|
|||
errorMessage?: string;
|
||||
hasBorder?: boolean;
|
||||
onRetry?: () => void;
|
||||
/** A footer that will be rendered on the bottom-left of the table. */
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
function Table<
|
||||
|
@ -61,6 +63,7 @@ function Table<
|
|||
errorMessage,
|
||||
hasBorder,
|
||||
onRetry,
|
||||
footer,
|
||||
}: Props<TFieldValues, TName>) {
|
||||
const totalColspan = columns.reduce((result, { colSpan }) => {
|
||||
return result + (colSpan ?? 1);
|
||||
|
@ -160,7 +163,10 @@ function Table<
|
|||
</table>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
{pagination && <Pagination className={styles.pagination} {...pagination} />}
|
||||
<div className={styles.footer}>
|
||||
{footer}
|
||||
{pagination && <Pagination className={styles.pagination} {...pagination} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -21,6 +21,16 @@
|
|||
color: var(--color-on-success-container);
|
||||
}
|
||||
|
||||
// Distinguish from the info status
|
||||
&.cell {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-neutral-variant-90);
|
||||
padding: _.unit(0.5) _.unit(2);
|
||||
}
|
||||
|
||||
&.info {
|
||||
.icon {
|
||||
background: var(--color-on-info-container);
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { HTMLProps, ReactNode } from 'react';
|
||||
|
||||
import Failed from '@/assets/icons/failed.svg';
|
||||
import Success from '@/assets/icons/success.svg';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = {
|
||||
export type Props = Pick<HTMLProps<HTMLDivElement>, 'className' | 'onClick'> & {
|
||||
type?: 'property' | 'state' | 'result';
|
||||
status?: 'info' | 'success' | 'alert' | 'error';
|
||||
variant?: 'plain' | 'outlined';
|
||||
className?: string;
|
||||
variant?: 'plain' | 'outlined' | 'cell';
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
|
@ -26,14 +25,15 @@ function Tag({
|
|||
variant = 'outlined',
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: Props) {
|
||||
const ResultIcon = conditional(type === 'result' && ResultIconMap[status]);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.tag, styles[status], styles[variant], className)}>
|
||||
<div className={classNames(styles.tag, styles[status], styles[variant], className)} {...rest}>
|
||||
{type === 'state' && <div className={styles.icon} />}
|
||||
{ResultIcon && <ResultIcon className={classNames(styles.icon, styles.resultIcon)} />}
|
||||
<div>{children}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import type { ConnectorResponse } from '@logto/schemas';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -41,7 +40,7 @@ function ConnectorDeleteButton({ connectorGroup }: Props) {
|
|||
try {
|
||||
await Promise.all(
|
||||
connectors.map(async (connector) => {
|
||||
await api.delete(`api/connectors/${connector.id}`).json<ConnectorResponse>();
|
||||
await api.delete(`api/connectors/${connector.id}`);
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
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';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onFinish: () => void;
|
||||
};
|
||||
|
||||
function CreatePermissionModal({ isOpen, onFinish }: Props) {
|
||||
const api = useApi();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const {
|
||||
reset,
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<{ name: string; description?: string }>({ defaultValues: { name: '' } });
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const addPermission = handleSubmit(async (json) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await api.post('api/organization-scopes', {
|
||||
json,
|
||||
});
|
||||
onFinish();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
reset();
|
||||
}
|
||||
}, [isOpen, reset]);
|
||||
|
||||
return (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
className={modalStyles.content}
|
||||
overlayClassName={modalStyles.overlay}
|
||||
onRequestClose={onFinish}
|
||||
>
|
||||
<ModalLayout
|
||||
title="organizations.create_organization_permission"
|
||||
footer={
|
||||
<Button
|
||||
type="primary"
|
||||
title="organizations.create_permission"
|
||||
isLoading={isLoading}
|
||||
onClick={addPermission}
|
||||
/>
|
||||
}
|
||||
onClose={onFinish}
|
||||
>
|
||||
<FormField isRequired title="general.name">
|
||||
<TextInput
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
placeholder="read:appointment"
|
||||
error={Boolean(errors.name)}
|
||||
{...register('name', { required: true })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="general.description">
|
||||
<TextInput
|
||||
placeholder={t('organizations.create_permission_placeholder')}
|
||||
error={Boolean(errors.description)}
|
||||
{...register('description')}
|
||||
/>
|
||||
</FormField>
|
||||
</ModalLayout>
|
||||
</ReactModal>
|
||||
);
|
||||
}
|
||||
export default CreatePermissionModal;
|
|
@ -0,0 +1,93 @@
|
|||
import { type OrganizationScope } from '@logto/schemas';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import DeleteButton from '@/components/DeleteButton';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
import { buildUrl } from '@/utils/url';
|
||||
|
||||
import CreatePermissionModal from '../CreatePermissionModal';
|
||||
import TemplateTable, { pageSize } from '../TemplateTable';
|
||||
import * as styles from '../index.module.scss';
|
||||
|
||||
/**
|
||||
* Renders the permissions field that allows users to add, edit, and delete organization
|
||||
* permissions.
|
||||
*/
|
||||
function PermissionsField() {
|
||||
const [page, setPage] = useState(1);
|
||||
const {
|
||||
data: response,
|
||||
error,
|
||||
mutate,
|
||||
} = useSWR<[OrganizationScope[], number], RequestError>(
|
||||
buildUrl('api/organization-scopes', {
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
})
|
||||
);
|
||||
|
||||
const [data, totalCount] = response ?? [[], 0];
|
||||
const api = useApi();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const isLoading = !response && !error;
|
||||
|
||||
if (isLoading) {
|
||||
return <>loading</>; // TODO: loading state
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField title="organizations.organization_permissions">
|
||||
<CreatePermissionModal
|
||||
isOpen={isModalOpen}
|
||||
onFinish={() => {
|
||||
setIsModalOpen(false);
|
||||
void mutate();
|
||||
}}
|
||||
/>
|
||||
<TemplateTable
|
||||
rowIndexKey="id"
|
||||
page={page}
|
||||
totalCount={totalCount}
|
||||
data={data}
|
||||
columns={[
|
||||
{
|
||||
title: t('general.name'),
|
||||
dataIndex: 'name',
|
||||
colSpan: 4,
|
||||
render: ({ name }) => <div className={styles.permission}>{name}</div>,
|
||||
},
|
||||
{
|
||||
title: t('general.description'),
|
||||
dataIndex: 'description',
|
||||
colSpan: 6,
|
||||
render: ({ description }) => description ?? '-',
|
||||
},
|
||||
{
|
||||
title: null,
|
||||
dataIndex: 'delete',
|
||||
render: ({ id }) => (
|
||||
<DeleteButton
|
||||
content="Delete at your own risk, mate."
|
||||
onDelete={async () => {
|
||||
await api.delete(`api/organization-scopes/${id}`);
|
||||
void mutate();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
onPageChange={setPage}
|
||||
onAdd={() => {
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionsField;
|
14
packages/console/src/pages/Organizations/Settings/index.tsx
Normal file
14
packages/console/src/pages/Organizations/Settings/index.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import FormCard from '@/components/FormCard';
|
||||
|
||||
import PermissionsField from '../PermissionsField';
|
||||
|
||||
export default function Settings() {
|
||||
return (
|
||||
<FormCard
|
||||
title="organizations.access_control"
|
||||
description="organizations.access_control_description"
|
||||
>
|
||||
<PermissionsField />
|
||||
</FormCard>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.addButton {
|
||||
margin-top: _.unit(3);
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: _.unit(3);
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
import { type FieldValues, type FieldPath } from 'react-hook-form';
|
||||
|
||||
import CirclePlus from '@/assets/icons/circle-plus.svg';
|
||||
import Plus from '@/assets/icons/plus.svg';
|
||||
import Button from '@/ds-components/Button';
|
||||
import Table from '@/ds-components/Table';
|
||||
import { type Column } from '@/ds-components/Table/types';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props<TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>> = {
|
||||
rowIndexKey: TName;
|
||||
data: TFieldValues[];
|
||||
columns: Array<Column<TFieldValues>>;
|
||||
totalCount: number;
|
||||
page: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onAdd?: () => void;
|
||||
};
|
||||
|
||||
export const pageSize = 10;
|
||||
|
||||
/**
|
||||
* The table component for organization template editing, such as permissions and roles.
|
||||
* If `onAdd` is provided, an add button will be rendered in the bottom.
|
||||
*/
|
||||
function TemplateTable<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
rowIndexKey,
|
||||
data,
|
||||
columns,
|
||||
onAdd,
|
||||
totalCount,
|
||||
page,
|
||||
onPageChange,
|
||||
}: Props<TFieldValues, TName>) {
|
||||
const hasData = data.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasData && (
|
||||
<Table
|
||||
hasBorder
|
||||
className={styles.table}
|
||||
rowGroups={[
|
||||
{
|
||||
key: 'data',
|
||||
data,
|
||||
},
|
||||
]}
|
||||
columns={columns}
|
||||
rowIndexKey={rowIndexKey}
|
||||
pagination={{
|
||||
page,
|
||||
totalCount,
|
||||
pageSize,
|
||||
onChange: onPageChange,
|
||||
}}
|
||||
footer={
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
className={styles.addButton}
|
||||
icon={<CirclePlus />}
|
||||
title="general.create_another"
|
||||
onClick={onAdd}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{onAdd && !hasData && (
|
||||
<Button
|
||||
className={styles.addButton}
|
||||
icon={<Plus />}
|
||||
title="general.create"
|
||||
onClick={onAdd}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TemplateTable;
|
|
@ -0,0 +1,9 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.tabs {
|
||||
margin: _.unit(4) 0;
|
||||
}
|
||||
|
||||
.permission {
|
||||
@include _.tag;
|
||||
}
|
42
packages/console/src/pages/Organizations/index.tsx
Normal file
42
packages/console/src/pages/Organizations/index.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { joinPath } from '@silverhand/essentials';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import CardTitle from '@/ds-components/CardTitle';
|
||||
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
|
||||
import * as pageLayout from '@/scss/page-layout.module.scss';
|
||||
|
||||
import Settings from './Settings';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const pathnames = Object.freeze({
|
||||
organizations: 'organizations',
|
||||
settings: 'settings',
|
||||
});
|
||||
|
||||
function Organizations() {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { tab } = useParams();
|
||||
|
||||
console.log('tab', tab);
|
||||
|
||||
return (
|
||||
<div className={pageLayout.container}>
|
||||
<div className={pageLayout.headline}>
|
||||
<CardTitle title="organizations.title" subtitle="organizations.subtitle" />
|
||||
</div>
|
||||
<TabNav className={styles.tabs}>
|
||||
<TabNavItem href={joinPath('..', pathnames.organizations)} isActive={!tab}>
|
||||
{t('organizations.title')}
|
||||
</TabNavItem>
|
||||
<TabNavItem href={pathnames.settings} isActive={tab === pathnames.settings}>
|
||||
{t('general.settings_nav')}
|
||||
</TabNavItem>
|
||||
</TabNav>
|
||||
{!tab && <>Not found</>}
|
||||
{tab === pathnames.settings && <Settings />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Organizations;
|
|
@ -96,3 +96,14 @@
|
|||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/** Render a tag that has background color and border radius. */
|
||||
@mixin tag {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
vertical-align: bottom;
|
||||
padding: unit(1) unit(2);
|
||||
border-radius: 6px;
|
||||
background: var(--color-neutral-95);
|
||||
@include text-ellipsis;
|
||||
}
|
||||
|
|
|
@ -88,9 +88,9 @@ export default class OrganizationQueries extends SchemaQueries<
|
|||
Organization
|
||||
> {
|
||||
/** Queries for roles in the organization template. */
|
||||
roles = new SchemaQueries(this.pool, OrganizationRoles);
|
||||
roles = new SchemaQueries(this.pool, OrganizationRoles, { field: 'name', order: 'asc' });
|
||||
/** Queries for scopes in the organization template. */
|
||||
scopes = new SchemaQueries(this.pool, OrganizationScopes);
|
||||
scopes = new SchemaQueries(this.pool, OrganizationScopes, { field: 'name', order: 'asc' });
|
||||
|
||||
/** Queries for relations that connected with organization-related entities. */
|
||||
relations = {
|
||||
|
|
|
@ -32,10 +32,11 @@ export default class SchemaQueries<
|
|||
|
||||
constructor(
|
||||
public readonly pool: CommonQueryMethods,
|
||||
public readonly schema: GeneratedSchema<Key | 'id', CreateSchema, Schema>
|
||||
public readonly schema: GeneratedSchema<Key | 'id', CreateSchema, Schema>,
|
||||
orderBy?: { field: Key | 'id'; order: 'asc' | 'desc' }
|
||||
) {
|
||||
this.#findTotalNumber = buildGetTotalRowCountWithPool(this.pool, this.schema.table);
|
||||
this.#findAll = buildFindAllEntitiesWithPool(this.pool)(this.schema);
|
||||
this.#findAll = buildFindAllEntitiesWithPool(this.pool)(this.schema, orderBy && [orderBy]);
|
||||
this.#findById = buildFindEntityByIdWithPool(this.pool)(this.schema);
|
||||
this.#insert = buildInsertIntoWithPool(this.pool)(this.schema, { returning: true });
|
||||
this.#updateById = buildUpdateWhereWithPool(this.pool)(this.schema, true);
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
expectConfirmModalAndAct,
|
||||
expectMainPageWithTitle,
|
||||
} from '#src/ui-helpers/index.js';
|
||||
import { appendPathname, expectNavigation } from '#src/utils.js';
|
||||
import { appendPathname, dcls, expectNavigation } from '#src/utils.js';
|
||||
|
||||
import { expectToCreateWebhook } from './helpers.js';
|
||||
|
||||
|
@ -97,13 +97,10 @@ describe('webhooks', () => {
|
|||
});
|
||||
await expectToClickModalAction(page, 'Disable webhook');
|
||||
|
||||
await expect(page).toMatchElement(
|
||||
'div[class$=header] div[class$=metadata] div:nth-of-type(2) div[class$=outlined] div:nth-of-type(2)',
|
||||
{
|
||||
text: 'Not in use',
|
||||
timeout: 1000,
|
||||
}
|
||||
);
|
||||
await expect(page).toMatchElement([dcls('header'), dcls('metadata'), dcls('tag')].join(' '), {
|
||||
text: 'Not in use',
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
// Reactivate webhook
|
||||
await expectToClickDetailsPageOption(page, 'Reactivate webhook');
|
||||
|
|
Loading…
Add table
Reference in a new issue