0
Fork 0
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:
Gao Sun 2023-10-23 14:32:17 +08:00
parent fbe9f7e89a
commit 23dc01c091
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
25 changed files with 419 additions and 95 deletions

View 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

View file

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

View file

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

View file

@ -10,4 +10,5 @@
.description {
font: var(--font-body-2);
color: var(--color-text-secondary);
white-space: pre-line;
}

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

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

View file

@ -0,0 +1,3 @@
.icon {
color: var(--color-specific-icon-bg);
}

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
export const decapitalize = (value: string) => value.charAt(0).toLowerCase() + value.slice(1);

View file

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