0
Fork 0
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:
Gao Sun 2023-10-19 21:29:36 -05:00 committed by GitHub
commit 70efc1b2c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 525 additions and 29 deletions

View 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

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

View file

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

View file

@ -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',

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 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 />} />

View file

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

View file

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

View file

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

View file

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

View file

@ -165,6 +165,12 @@
}
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.pagination {
margin-top: _.unit(4);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -0,0 +1,9 @@
@use '@/scss/underscore' as _;
.addButton {
margin-top: _.unit(3);
}
.table {
margin-top: _.unit(3);
}

View file

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

View file

@ -0,0 +1,9 @@
@use '@/scss/underscore' as _;
.tabs {
margin: _.unit(4) 0;
}
.permission {
@include _.tag;
}

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

View file

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

View file

@ -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 = {

View file

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

View file

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