mirror of
https://github.com/logto-io/logto.git
synced 2025-02-03 21:48:55 -05:00
feat: draft
This commit is contained in:
parent
95f4ba1856
commit
c5283a1c2d
16 changed files with 353 additions and 4 deletions
|
@ -26,7 +26,7 @@
|
|||
"@fontsource/roboto-mono": "^5.0.0",
|
||||
"@jest/types": "^29.5.0",
|
||||
"@logto/app-insights": "workspace:^1.4.0",
|
||||
"@logto/cloud": "0.2.5-4ef0b45",
|
||||
"@logto/cloud": "0.2.5-d9576f9",
|
||||
"@logto/connector-kit": "workspace:^2.1.0",
|
||||
"@logto/core-kit": "workspace:^2.3.0",
|
||||
"@logto/language-kit": "workspace:^1.1.0",
|
||||
|
|
3
packages/console/src/assets/icons/invitation.svg
Normal file
3
packages/console/src/assets/icons/invitation.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6666 7.38666H5.33331C4.80288 7.38666 4.29417 7.59737 3.9191 7.97244C3.54403 8.34751 3.33331 8.85622 3.33331 9.38666V12.72C3.33331 13.2504 3.54403 13.7591 3.9191 14.1342C4.29417 14.5093 4.80288 14.72 5.33331 14.72H10.6666C11.1971 14.72 11.7058 14.5093 12.0809 14.1342C12.4559 13.7591 12.6666 13.2504 12.6666 12.72V9.38666C12.6666 8.85622 12.4559 8.34751 12.0809 7.97244C11.7058 7.59737 11.1971 7.38666 10.6666 7.38666ZM10.3866 8.71999L8.46665 10.6667C8.34203 10.7888 8.17448 10.8572 7.99998 10.8572C7.82548 10.8572 7.65793 10.7888 7.53331 10.6667L5.61331 8.71999H10.3866ZM11.3333 12.72C11.3333 12.8968 11.2631 13.0664 11.1381 13.1914C11.013 13.3164 10.8435 13.3867 10.6666 13.3867H5.33331C5.1565 13.3867 4.98693 13.3164 4.86191 13.1914C4.73688 13.0664 4.66665 12.8968 4.66665 12.72V9.65999L6.58665 11.58C6.96165 11.9545 7.46998 12.1649 7.99998 12.1649C8.52998 12.1649 9.03831 11.9545 9.41331 11.58L11.3333 9.65999V12.72ZM7.13998 3.74666L7.33331 3.55332V5.38666C7.33331 5.56347 7.40355 5.73304 7.52858 5.85806C7.6536 5.98308 7.82317 6.05332 7.99998 6.05332C8.17679 6.05332 8.34636 5.98308 8.47138 5.85806C8.59641 5.73304 8.66665 5.56347 8.66665 5.38666V3.55332L8.85998 3.74666C8.9217 3.80881 8.99506 3.85821 9.07587 3.89202C9.15668 3.92582 9.24336 3.94338 9.33096 3.94369C9.41855 3.944 9.50535 3.92706 9.5864 3.89382C9.66745 3.86058 9.74115 3.81171 9.80331 3.74999C9.86547 3.68827 9.91487 3.61491 9.94867 3.5341C9.98248 3.45329 10 3.36661 10.0004 3.27901C10.0007 3.19142 9.98371 3.10462 9.95048 3.02357C9.91724 2.94252 9.86837 2.86881 9.80665 2.80666L8.47331 1.47332C8.41134 1.41084 8.3376 1.36124 8.25636 1.32739C8.17512 1.29355 8.08799 1.27612 7.99998 1.27612C7.91197 1.27612 7.82483 1.29355 7.7436 1.32739C7.66236 1.36124 7.58862 1.41084 7.52665 1.47332L6.19331 2.80666C6.06915 2.93156 5.99945 3.10053 5.99945 3.27666C5.99945 3.45278 6.06915 3.62175 6.19331 3.74666C6.25529 3.80914 6.32902 3.85874 6.41026 3.89258C6.4915 3.92643 6.57864 3.94385 6.66665 3.94385C6.75465 3.94385 6.84179 3.92643 6.92303 3.89258C7.00427 3.85874 7.078 3.80914 7.13998 3.74666Z" fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.1 KiB |
3
packages/console/src/assets/icons/members.svg
Normal file
3
packages/console/src/assets/icons/members.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.20002 8.14671C8.55574 7.8388 8.84105 7.45796 9.0366 7.03006C9.23215 6.60215 9.33336 6.13718 9.33335 5.66671C9.33335 4.78265 8.98216 3.93481 8.35704 3.30968C7.73192 2.68456 6.88408 2.33337 6.00002 2.33337C5.11597 2.33337 4.26812 2.68456 3.643 3.30968C3.01788 3.93481 2.66669 4.78265 2.66669 5.66671C2.66668 6.13718 2.76789 6.60215 2.96344 7.03006C3.15899 7.45796 3.4443 7.8388 3.80002 8.14671C2.86678 8.5693 2.075 9.25173 1.51934 10.1124C0.963684 10.9731 0.667668 11.9756 0.666687 13C0.666687 13.1769 0.736925 13.3464 0.861949 13.4714C0.986973 13.5965 1.15654 13.6667 1.33335 13.6667C1.51016 13.6667 1.67973 13.5965 1.80476 13.4714C1.92978 13.3464 2.00002 13.1769 2.00002 13C2.00002 11.9392 2.42145 10.9218 3.17159 10.1716C3.92174 9.42147 4.93915 9.00004 6.00002 9.00004C7.06089 9.00004 8.0783 9.42147 8.82845 10.1716C9.57859 10.9218 10 11.9392 10 13C10 13.1769 10.0703 13.3464 10.1953 13.4714C10.3203 13.5965 10.4899 13.6667 10.6667 13.6667C10.8435 13.6667 11.0131 13.5965 11.1381 13.4714C11.2631 13.3464 11.3334 13.1769 11.3334 13C11.3324 11.9756 11.0364 10.9731 10.4807 10.1124C9.92504 9.25173 9.13326 8.5693 8.20002 8.14671ZM6.00002 7.66671C5.60446 7.66671 5.21778 7.54941 4.88888 7.32965C4.55998 7.10988 4.30364 6.79753 4.15226 6.43207C4.00089 6.06662 3.96128 5.66449 4.03845 5.27653C4.11562 4.88856 4.3061 4.5322 4.58581 4.25249C4.86551 3.97279 5.22188 3.78231 5.60984 3.70514C5.9978 3.62797 6.39994 3.66757 6.76539 3.81895C7.13084 3.97032 7.4432 4.22667 7.66296 4.55557C7.88272 4.88447 8.00002 5.27114 8.00002 5.66671C8.00002 6.19714 7.78931 6.70585 7.41423 7.08092C7.03916 7.45599 6.53045 7.66671 6.00002 7.66671ZM12.4934 7.88004C12.92 7.39959 13.1987 6.80608 13.2959 6.17093C13.3931 5.53579 13.3046 4.88609 13.0412 4.30004C12.7778 3.71399 12.3505 3.21657 11.811 2.86766C11.2715 2.51874 10.6426 2.3332 10 2.33337C9.82321 2.33337 9.65364 2.40361 9.52862 2.52864C9.40359 2.65366 9.33335 2.82323 9.33335 3.00004C9.33335 3.17685 9.40359 3.34642 9.52862 3.47145C9.65364 3.59647 9.82321 3.66671 10 3.66671C10.5305 3.66671 11.0392 3.87742 11.4142 4.25249C11.7893 4.62757 12 5.13627 12 5.66671C11.9991 6.01687 11.9062 6.36064 11.7307 6.66365C11.5552 6.96667 11.3033 7.21829 11 7.39337C10.9012 7.45039 10.8186 7.53182 10.7603 7.62987C10.7019 7.72792 10.6697 7.83931 10.6667 7.95337C10.6639 8.06655 10.69 8.17857 10.7425 8.27888C10.7949 8.37919 10.8721 8.46448 10.9667 8.52671L11.2267 8.70004L11.3134 8.74671C12.117 9.12785 12.7949 9.7307 13.2673 10.4843C13.7398 11.2378 13.9871 12.1107 13.98 13C13.98 13.1769 14.0503 13.3464 14.1753 13.4714C14.3003 13.5965 14.4699 13.6667 14.6467 13.6667C14.8235 13.6667 14.9931 13.5965 15.1181 13.4714C15.2431 13.3464 15.3134 13.1769 15.3134 13C15.3188 11.977 15.0626 10.9695 14.569 10.0734C14.0754 9.17729 13.3609 8.42225 12.4934 7.88004Z" fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.8 KiB |
|
@ -1,4 +1,5 @@
|
|||
import type router from '@logto/cloud/routes';
|
||||
import { type tenantAuthRouter } from '@logto/cloud/routes';
|
||||
import { useLogto } from '@logto/react';
|
||||
import { conditional, trySafe } from '@silverhand/essentials';
|
||||
import Client, { ResponseError } from '@withtyped/client';
|
||||
|
@ -57,3 +58,27 @@ export const useCloudApi = ({ hideErrorToast = false }: UseCloudApiProps = {}):
|
|||
|
||||
return api;
|
||||
};
|
||||
|
||||
// TODO: @charles - Remove this hook when the `tenantAuthRouter` is merged into cloud `router`.
|
||||
export const useAuthedCloudApi = ({ hideErrorToast = false }: UseCloudApiProps = {}): Client<
|
||||
typeof tenantAuthRouter
|
||||
> => {
|
||||
const { isAuthenticated, getAccessToken } = useLogto();
|
||||
const api = useMemo(
|
||||
() =>
|
||||
new Client<typeof tenantAuthRouter>({
|
||||
baseUrl: window.location.origin,
|
||||
headers: async () => {
|
||||
if (isAuthenticated) {
|
||||
return { Authorization: `Bearer ${(await getAccessToken(cloudApi.indicator)) ?? ''}` };
|
||||
}
|
||||
},
|
||||
before: {
|
||||
...conditional(!hideErrorToast && { error: toastResponseError }),
|
||||
},
|
||||
}),
|
||||
[getAccessToken, hideErrorToast, isAuthenticated]
|
||||
);
|
||||
|
||||
return api;
|
||||
};
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import type router from '@logto/cloud/routes';
|
||||
import { type tenantAuthRouter } from '@logto/cloud/routes';
|
||||
import { type GuardedResponse, type RouterRoutes } from '@withtyped/client';
|
||||
|
||||
type GetRoutes = RouterRoutes<typeof router>['get'];
|
||||
type GetTenantAuthRoutes = RouterRoutes<typeof tenantAuthRouter>['get'];
|
||||
|
||||
export type GetArrayElementType<T> = T extends Array<infer U> ? U : never;
|
||||
|
||||
|
@ -17,3 +19,7 @@ export type InvoicesResponse = GuardedResponse<GetRoutes['/api/tenants/:tenantId
|
|||
|
||||
// The response of GET /api/tenants is TenantResponse[].
|
||||
export type TenantResponse = GetArrayElementType<GuardedResponse<GetRoutes['/api/tenants']>>;
|
||||
|
||||
export type TenantMemberResponse = GetArrayElementType<
|
||||
GuardedResponse<GetTenantAuthRoutes['/api/tenants/:tenantId/members']>
|
||||
>;
|
||||
|
|
|
@ -9,7 +9,8 @@ import UserAvatar from '../UserAvatar';
|
|||
import ItemPreview from '.';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
user: Pick<User, 'id' | 'avatar' | 'name' | 'primaryEmail' | 'primaryPhone' | 'username'> &
|
||||
Partial<Pick<User, 'isSuspended'>>;
|
||||
};
|
||||
|
||||
/** A component that renders a preview of a user. It's useful for displaying a user in a list. */
|
||||
|
|
|
@ -37,6 +37,7 @@ export enum RoleDetailsTabs {
|
|||
|
||||
export enum TenantSettingsTabs {
|
||||
Settings = 'settings',
|
||||
Members = 'members',
|
||||
Domains = 'domains',
|
||||
Subscription = 'subscription',
|
||||
BillingHistory = 'billing-history',
|
||||
|
|
|
@ -52,6 +52,7 @@ import BillingHistory from '@/pages/TenantSettings/BillingHistory';
|
|||
import Subscription from '@/pages/TenantSettings/Subscription';
|
||||
import TenantBasicSettings from '@/pages/TenantSettings/TenantBasicSettings';
|
||||
import TenantDomainSettings from '@/pages/TenantSettings/TenantDomainSettings';
|
||||
import TenantMembers from '@/pages/TenantSettings/TenantMembers';
|
||||
import UserDetails from '@/pages/UserDetails';
|
||||
import UserLogs from '@/pages/UserDetails/UserLogs';
|
||||
import UserOrganizations from '@/pages/UserDetails/UserOrganizations';
|
||||
|
@ -195,6 +196,7 @@ function ConsoleContent() {
|
|||
<Route path="tenant-settings" element={<TenantSettings />}>
|
||||
<Route index element={<Navigate replace to={TenantSettingsTabs.Settings} />} />
|
||||
<Route path={TenantSettingsTabs.Settings} element={<TenantBasicSettings />} />
|
||||
<Route path={`${TenantSettingsTabs.Members}/*`} element={<TenantMembers />} />
|
||||
<Route path={TenantSettingsTabs.Domains} element={<TenantDomainSettings />} />
|
||||
{!isDevTenant && (
|
||||
<>
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
import { TenantRole } from '@logto/schemas';
|
||||
import { useContext, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactModal from 'react-modal';
|
||||
|
||||
import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import { type TenantMemberResponse } from '@/cloud/types/router';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import Button from '@/ds-components/Button';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import ModalLayout from '@/ds-components/ModalLayout';
|
||||
import Select from '@/ds-components/Select';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
type Props = {
|
||||
user: TenantMemberResponse;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const roles = Object.freeze([TenantRole.Admin, TenantRole.Member]);
|
||||
|
||||
function EditMemberModal({ user, isOpen, onClose }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
|
||||
const name = user.name ?? '';
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const cloudApi = useAuthedCloudApi();
|
||||
|
||||
const onSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await cloudApi.put(`/api/tenants/:tenantId/members/:userId/roles`, {
|
||||
params: { tenantId: currentTenantId, userId: user.id },
|
||||
body: { roleName: TenantRole.Admin },
|
||||
});
|
||||
onClose();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
className={modalStyles.content}
|
||||
overlayClassName={modalStyles.overlay}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<ModalLayout
|
||||
title={
|
||||
<>
|
||||
{t('organization_details.edit_organization_roles_of_user', {
|
||||
name,
|
||||
})}
|
||||
</>
|
||||
}
|
||||
subtitle={<>{t('organization_details.authorize_to_roles', { name })}</>}
|
||||
footer={
|
||||
<Button
|
||||
size="large"
|
||||
type="primary"
|
||||
title="general.save"
|
||||
isLoading={isLoading}
|
||||
onClick={onSubmit}
|
||||
/>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<FormField title="organizations.organization_role_other">
|
||||
<Select options={roles} />
|
||||
</FormField>
|
||||
</ModalLayout>
|
||||
</ReactModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditMemberModal;
|
|
@ -0,0 +1,5 @@
|
|||
function Invitations() {
|
||||
return <div>Invitations</div>;
|
||||
}
|
||||
|
||||
export default Invitations;
|
|
@ -0,0 +1,7 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: _.unit(2);
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
import { useContext, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import { type TenantMemberResponse } from '@/cloud/types/router';
|
||||
import ActionsButton from '@/components/ActionsButton';
|
||||
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
|
||||
import UserPreview from '@/components/ItemPreview/UserPreview';
|
||||
import { RoleOption } from '@/components/OrganizationRolesSelect';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import Table from '@/ds-components/Table';
|
||||
import Tag from '@/ds-components/Tag';
|
||||
import { type RequestError } from '@/hooks/use-api';
|
||||
|
||||
import EditMemberModal from '../EditMemberModal';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
function Members() {
|
||||
const cloudApi = useAuthedCloudApi();
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
|
||||
const { data, error, isLoading, mutate } = useSWR<TenantMemberResponse[], RequestError>(
|
||||
`api/tenant/${currentTenantId}/members`,
|
||||
async () =>
|
||||
cloudApi.get('/api/tenants/:tenantId/members', { params: { tenantId: currentTenantId } })
|
||||
);
|
||||
|
||||
const [userToBeEdited, setUserToBeEdited] = useState<TenantMemberResponse>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
isRowHoverEffectDisabled
|
||||
placeholder={<EmptyDataPlaceholder />}
|
||||
isLoading={isLoading}
|
||||
errorMessage={error?.toString()}
|
||||
rowGroups={[{ key: 'data', data }]}
|
||||
columns={[
|
||||
{
|
||||
dataIndex: 'user',
|
||||
title: 'User',
|
||||
colSpan: 4,
|
||||
render: (user) => <UserPreview user={user} />,
|
||||
},
|
||||
{
|
||||
dataIndex: 'roles',
|
||||
title: 'Organization roles',
|
||||
colSpan: 6,
|
||||
render: ({ organizationRoles }) => {
|
||||
if (organizationRoles.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.roles}>
|
||||
{organizationRoles.map(({ id, name }) => (
|
||||
<Tag key={id} variant="cell">
|
||||
<RoleOption value={id} title={name} />
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: null,
|
||||
colSpan: 1,
|
||||
render: (user) => (
|
||||
<ActionsButton
|
||||
deleteConfirmation="organization_details.remove_user_from_organization_description"
|
||||
fieldName="organization_details.user"
|
||||
textOverrides={{
|
||||
edit: 'organization_details.edit_organization_roles',
|
||||
delete: 'organization_details.remove_user_from_organization',
|
||||
deleteConfirmation: 'general.remove',
|
||||
}}
|
||||
onEdit={() => {
|
||||
// SetUserToBeEdited(user);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
await cloudApi.delete(`/api/tenants/:tenantId/members/:userId`, {
|
||||
params: { tenantId: currentTenantId, userId: user.id },
|
||||
});
|
||||
void mutate();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
rowIndexKey="id"
|
||||
/>
|
||||
{userToBeEdited && (
|
||||
<EditMemberModal
|
||||
isOpen
|
||||
user={userToBeEdited}
|
||||
onClose={() => {
|
||||
setUserToBeEdited(undefined);
|
||||
void mutate();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Members;
|
|
@ -0,0 +1,47 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.tabButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: _.unit(4);
|
||||
|
||||
.button {
|
||||
border-radius: 100px;
|
||||
border-color: var(--color-specific-focused-inside);
|
||||
background: none;
|
||||
height: 32px;
|
||||
padding: 0 _.unit(3);
|
||||
|
||||
svg {
|
||||
color: var(--color-text-link);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-overlay-primary-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--color-overlay-primary-pressed);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-specific-tag-upsell);
|
||||
border-color: var(--color-specific-tag-upsell);
|
||||
color: var(--color-static-white);
|
||||
cursor: default;
|
||||
|
||||
svg {
|
||||
color: var(--color-specific-button-icon);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-specific-tag-upsell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import InvitationIcon from '@/assets/icons/invitation.svg';
|
||||
import MembersIcon from '@/assets/icons/members.svg';
|
||||
import PlusIcon from '@/assets/icons/plus.svg';
|
||||
import { TenantSettingsTabs } from '@/consts';
|
||||
import Button from '@/ds-components/Button';
|
||||
import Spacer from '@/ds-components/Spacer';
|
||||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
|
||||
import Invitations from './Invitations';
|
||||
import Members from './Members';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const invitationsRoute = 'invitations';
|
||||
|
||||
function TenantMembers() {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { navigate, match } = useTenantPathname();
|
||||
|
||||
const isInvitationTab = match(
|
||||
`/tenant-settings/${TenantSettingsTabs.Members}/${invitationsRoute}`
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.tabButtons}>
|
||||
<Button
|
||||
className={classNames(styles.button, !isInvitationTab && styles.active)}
|
||||
icon={<MembersIcon />}
|
||||
title="tenant_members.members"
|
||||
onClick={() => {
|
||||
navigate('.');
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className={classNames(styles.button, isInvitationTab && styles.active)}
|
||||
icon={<InvitationIcon />}
|
||||
title="tenant_members.invitations"
|
||||
onClick={() => {
|
||||
navigate('invitations');
|
||||
}}
|
||||
/>
|
||||
<Spacer />
|
||||
<Button type="primary" size="large" icon={<PlusIcon />} title="tenant_members.new_member" />
|
||||
</div>
|
||||
<Routes>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
<Route index element={<Members />} />
|
||||
<Route path={invitationsRoute} element={<Invitations />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantMembers;
|
|
@ -22,6 +22,9 @@ function TenantSettings() {
|
|||
<TabNavItem href={`/tenant-settings/${TenantSettingsTabs.Settings}`}>
|
||||
<DynamicT forKey="tenants.tabs.settings" />
|
||||
</TabNavItem>
|
||||
<TabNavItem href={`/tenant-settings/${TenantSettingsTabs.Members}`}>
|
||||
<DynamicT forKey="tenants.tabs.members" />
|
||||
</TabNavItem>
|
||||
<TabNavItem href={`/tenant-settings/${TenantSettingsTabs.Domains}`}>
|
||||
<DynamicT forKey="tenants.tabs.domains" />
|
||||
</TabNavItem>
|
||||
|
|
|
@ -2,10 +2,10 @@ import type { User } from '@logto/schemas';
|
|||
import { getUserDisplayName } from '@logto/shared/universal';
|
||||
import { t } from 'i18next';
|
||||
|
||||
export const getUserTitle = (user?: User): string =>
|
||||
export const getUserTitle = (user?: Partial<User>): string =>
|
||||
(user ? getUserDisplayName(user) : undefined) ?? t('admin_console.users.unnamed');
|
||||
|
||||
export const getUserSubtitle = (user?: User) => {
|
||||
export const getUserSubtitle = (user?: Partial<User>) => {
|
||||
if (!user?.name) {
|
||||
return;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue