0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

feat(console): organizations tab for user details

This commit is contained in:
Gao Sun 2023-10-25 20:30:05 +08:00
parent 889ca18e66
commit 54fd771201
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
23 changed files with 223 additions and 0 deletions

View file

@ -24,6 +24,7 @@ export enum UserDetailsTabs {
Settings = 'settings',
Roles = 'roles',
Logs = 'logs',
Organizations = 'organizations',
}
export enum RoleDetailsTabs {

View file

@ -47,6 +47,7 @@ import TenantBasicSettings from '@/pages/TenantSettings/TenantBasicSettings';
import TenantDomainSettings from '@/pages/TenantSettings/TenantDomainSettings';
import UserDetails from '@/pages/UserDetails';
import UserLogs from '@/pages/UserDetails/UserLogs';
import UserOrganizations from '@/pages/UserDetails/UserOrganizations';
import UserRoles from '@/pages/UserDetails/UserRoles';
import UserSettings from '@/pages/UserDetails/UserSettings';
import Users from '@/pages/Users';
@ -130,6 +131,7 @@ function ConsoleContent() {
<Route path={UserDetailsTabs.Settings} element={<UserSettings />} />
<Route path={UserDetailsTabs.Roles} element={<UserRoles />} />
<Route path={UserDetailsTabs.Logs} element={<UserLogs />} />
<Route path={UserDetailsTabs.Organizations} element={<UserOrganizations />} />
</Route>
<Route
path={`:userId/${UserDetailsTabs.Logs}/:logId`}

View file

@ -6,6 +6,7 @@ import useSWR from 'swr';
import Plus from '@/assets/icons/plus.svg';
import ActionsButton from '@/components/ActionsButton';
import DateTime from '@/components/DateTime';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import UserPreview from '@/components/ItemPreview/UserPreview';
import { defaultPageSize } from '@/consts';
import Button from '@/ds-components/Button';
@ -52,6 +53,7 @@ function Members({ organization }: Props) {
return (
<>
<Table
placeholder={<EmptyDataPlaceholder />}
pagination={{
page,
totalCount,

View file

@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import OrganizationIcon from '@/assets/icons/organization-preview.svg';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import ItemPreview from '@/components/ItemPreview';
import ThemedIcon from '@/components/ThemedIcon';
import { defaultPageSize } from '@/consts';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import Search from '@/ds-components/Search';
import Table from '@/ds-components/Table';
import { type RequestError } from '@/hooks/use-api';
import AssignedEntities from '@/pages/Roles/components/AssignedEntities';
@ -19,9 +21,11 @@ const pathname = '/organizations';
const apiPathname = 'api/organizations';
function OrganizationsTable() {
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1);
const { data: response, error } = useSWR<[OrganizationWithFeatured[], number], RequestError>(
buildUrl(apiPathname, {
q: keyword,
showFeatured: '1',
page: String(page),
page_size: String(pageSize),
@ -34,6 +38,7 @@ function OrganizationsTable() {
return (
<Table
isLoading={isLoading}
placeholder={<EmptyDataPlaceholder />}
rowGroups={[{ key: 'data', data }]}
columns={[
{
@ -71,6 +76,21 @@ function OrganizationsTable() {
pageSize,
onChange: setPage,
}}
filter={
<Search
defaultValue={keyword}
isClearable={Boolean(keyword)}
placeholder={t('organization_details.search_user_placeholder')}
onSearch={(value) => {
setKeyword(value);
setPage(1);
}}
onClearSearch={() => {
setKeyword('');
setPage(1);
}}
/>
}
/>
);
}

View file

@ -2,6 +2,7 @@ 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 EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import Button from '@/ds-components/Button';
import Table from '@/ds-components/Table';
import { type Column } from '@/ds-components/Table/types';
@ -44,6 +45,7 @@ function TemplateTable<
<>
<Table
hasBorder
placeholder={<EmptyDataPlaceholder />}
isLoading={isLoading}
className={styles.table}
rowGroups={[

View file

@ -0,0 +1,13 @@
@use '@/scss/underscore' as _;
.roles {
display: flex;
flex-wrap: wrap;
gap: _.unit(2);
}
.rolesHeader {
display: flex;
align-items: center;
gap: _.unit(0.5);
}

View file

@ -0,0 +1,107 @@
import { type OrganizationWithRoles } from '@logto/schemas';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import useSWR from 'swr';
import OrganizationIcon from '@/assets/icons/organization-preview.svg';
import Tip from '@/assets/icons/tip.svg';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import ItemPreview from '@/components/ItemPreview';
import ThemedIcon from '@/components/ThemedIcon';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import IconButton from '@/ds-components/IconButton';
import Search from '@/ds-components/Search';
import Table from '@/ds-components/Table';
import Tag from '@/ds-components/Tag';
import { ToggleTip } from '@/ds-components/Tip';
import { type RequestError } from '@/hooks/use-api';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import { buildUrl } from '@/utils/url';
import { type UserDetailsOutletContext } from '../types';
import * as styles from './index.module.scss';
function UserOrganizations() {
const [keyword, setKeyword] = useState('');
const {
user: { id },
} = useOutletContext<UserDetailsOutletContext>();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { getPathname } = useTenantPathname();
// Since this API has no pagination (to align with ID token claims):
// - We don't need to use the `page` state.
// - We can perform frontend filtering.
const { data: rawData, error } = useSWR<OrganizationWithRoles[], RequestError>(
buildUrl(`api/users/${id}/organizations`, { showFeatured: '1' })
);
const isLoading = !rawData && !error;
const data = rawData?.filter(({ name }) => name.toLowerCase().includes(keyword.toLowerCase()));
return (
<Table
isLoading={isLoading}
rowIndexKey="id"
rowGroups={[{ key: 'data', data }]}
placeholder={<EmptyDataPlaceholder />}
columns={[
{
title: t('general.name'),
dataIndex: 'name',
render: ({ name, id }) => (
<ItemPreview
title={name}
icon={<ThemedIcon for={OrganizationIcon} />}
to={getPathname(`/organizations/${id}`)}
/>
),
},
{
title: t('organizations.organization_id'),
dataIndex: 'id',
render: ({ id }) => <CopyToClipboard value={id} variant="text" />,
},
{
title: (
<div className={styles.rolesHeader}>
{t('organizations.organization_role_other')}
<ToggleTip
content={t('user_details.organization_roles_tooltip')}
horizontalAlign="start"
>
<IconButton size="small">
<Tip />
</IconButton>
</ToggleTip>
</div>
),
dataIndex: 'roles',
render: ({ organizationRoles }) => (
<div className={styles.roles}>
{organizationRoles.map(({ id, name }) => (
<Tag key={id} variant="cell">
{name}
</Tag>
))}
</div>
),
},
]}
filter={
<Search
defaultValue={keyword}
isClearable={Boolean(keyword)}
placeholder={t('organization_details.search_user_placeholder')}
onSearch={setKeyword}
onClearSearch={() => {
setKeyword('');
}}
/>
}
/>
);
}
export default UserOrganizations;

View file

@ -222,6 +222,9 @@ function UserDetails() {
<TabNavItem href={`/users/${data.id}/${UserDetailsTabs.Logs}`}>
{t('user_details.tab_logs')}
</TabNavItem>
<TabNavItem href={`/users/${data.id}/${UserDetailsTabs.Organizations}`}>
{t('user_details.tab_organizations')}
</TabNavItem>
</TabNav>
<Outlet
context={

View file

@ -23,6 +23,8 @@ const user_details = {
tab_roles: 'Rollen',
tab_logs: 'Benutzer-Logs',
/** UNTRANSLATED */
tab_organizations: 'Organizations',
/** UNTRANSLATED */
authentication: 'Authentication',
authentication_description:
'Jeder Benutzer hat ein Profil mit allen Benutzerinformationen. Es besteht aus Basisdaten, sozialen Identitäten und benutzerdefinierten Daten.',
@ -103,6 +105,9 @@ const user_details = {
},
warning_no_sign_in_identifier:
'Der Benutzer muss mindestens einen der Anmelde-Identifikatoren (Benutzername, E-Mail, Telefonnummer oder soziales Konto) haben, um sich anzumelden. Sind Sie sicher, dass Sie fortfahren möchten?',
/** UNTRANSLATED */
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);

View file

@ -20,6 +20,7 @@ const user_details = {
tab_settings: 'Settings',
tab_roles: 'Roles',
tab_logs: 'User logs',
tab_organizations: 'Organizations',
authentication: 'Authentication',
authentication_description:
'Each user has a profile containing all user information. It consists of basic data, social identities, and custom data.',
@ -92,6 +93,8 @@ const user_details = {
},
warning_no_sign_in_identifier:
'User needs to have at least one of the sign-in identifiers (username, email, phone number or social) to sign in. Are you sure you want to continue?',
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);

View file

@ -23,6 +23,8 @@ const user_details = {
tab_roles: 'Roles',
tab_logs: 'Registros de usuario',
/** UNTRANSLATED */
tab_organizations: 'Organizations',
/** UNTRANSLATED */
authentication: 'Authentication',
authentication_description:
'Cada usuario tiene un perfil que contiene toda la información del usuario. Consta de datos básicos, identidades sociales y datos personalizados.',
@ -102,6 +104,9 @@ const user_details = {
},
warning_no_sign_in_identifier:
'El usuario necesita tener al menos uno de los identificadores de inicio de sesión (nombre de usuario, correo electrónico, número de teléfono o red social) para iniciar sesión. ¿Estás seguro/a de que quieres continuar?',
/** UNTRANSLATED */
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);

View file

@ -23,6 +23,8 @@ const user_details = {
tab_roles: 'Rôles',
tab_logs: "Journaux de l'utilisateur",
/** UNTRANSLATED */
tab_organizations: 'Organizations',
/** UNTRANSLATED */
authentication: 'Authentication',
authentication_description:
"Chaque utilisateur possède un profil contenant toutes les informations le concernant. Il se compose de données de base, d'identités sociales et de données personnalisées.",
@ -103,6 +105,9 @@ const user_details = {
},
warning_no_sign_in_identifier:
"L'utilisateur doit avoir au moins l'un des identifiants de connexion (nom d'utilisateur, e-mail, numéro de téléphone ou compte social) pour se connecter. Êtes-vous sûr(e) de vouloir continuer?",
/** UNTRANSLATED */
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);

View file

@ -23,6 +23,8 @@ const user_details = {
tab_roles: 'Ruoli',
tab_logs: 'Log utente',
/** UNTRANSLATED */
tab_organizations: 'Organizations',
/** UNTRANSLATED */
authentication: 'Authentication',
authentication_description:
"Ogni utente ha un profilo contenente tutte le informazioni dell'utente. È composto da dati di base, identità sociali e dati personalizzati.",
@ -103,6 +105,9 @@ const user_details = {
},
warning_no_sign_in_identifier:
"L'utente deve avere almeno uno degli identificatori di accesso (nome utente, email, numero di telefono, o social) per accedere. Sei sicuro di voler continuare?",
/** UNTRANSLATED */
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);

View file

@ -21,6 +21,8 @@ const user_details = {
tab_roles: '役割',
tab_logs: 'ユーザーログ',
/** UNTRANSLATED */
tab_organizations: 'Organizations',
/** UNTRANSLATED */
authentication: 'Authentication',
authentication_description:
'各ユーザーには、すべてのユーザー情報が含まれるプロファイルがあります。基本データ、ソーシャルアイデンティティ、およびカスタムデータで構成されています。',
@ -99,6 +101,9 @@ const user_details = {
},
warning_no_sign_in_identifier:
'ユーザーは、サインインに少なくとも1つの識別子ユーザー名、メールアドレス、電話番号、またはソーシャルを持っている必要があります。続行してよろしいですか',
/** UNTRANSLATED */
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);

View file

@ -21,6 +21,8 @@ const user_details = {
tab_roles: '역할',
tab_logs: '사용자 기록',
/** UNTRANSLATED */
tab_organizations: 'Organizations',
/** UNTRANSLATED */
authentication: 'Authentication',
authentication_description:
'각 사용자는 모든 사용자 정보를 포함하는 프로파일을 가지고 있어요. 프로파일은 기본 데이터, 소셜 ID, 사용자 정의 데이터로 구성되어 있어요.',
@ -99,6 +101,9 @@ const user_details = {
},
warning_no_sign_in_identifier:
'사용자는 로그인 식별자(사용자 이름, 이메일, 전화 번호 또는 소셜) 중 적어도 하나를 갖고 로그인해야 합니다. 계속 하시겠습니까?',
/** UNTRANSLATED */
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);

View file

@ -21,6 +21,8 @@ const user_details = {
tab_roles: 'Role',
tab_logs: 'Logi użytkownika',
/** UNTRANSLATED */
tab_organizations: 'Organizations',
/** UNTRANSLATED */
authentication: 'Authentication',
authentication_description:
'Każdy użytkownik ma profil zawierający wszystkie informacje o użytkowniku. Składa się on z podstawowych danych, tożsamości społecznościowych i niestandardowych danych.',
@ -99,6 +101,9 @@ const user_details = {
},
warning_no_sign_in_identifier:
'Aby się zalogować, użytkownik musi mieć co najmniej jeden z identyfikatorów logowania (nazwa użytkownika, e-mail, numer telefonu lub konto społecznościowe). Czy na pewno chcesz kontynuować?',
/** UNTRANSLATED */
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);

View file

@ -21,6 +21,8 @@ const user_details = {
tab_roles: 'Funções',
tab_logs: 'Registros',
/** UNTRANSLATED */
tab_organizations: 'Organizations',
/** UNTRANSLATED */
authentication: 'Authentication',
authentication_description:
'Cada usuário tem um perfil contendo todas as informações do usuário. Consiste em dados básicos, identidades sociais e dados personalizados.',
@ -100,6 +102,9 @@ const user_details = {
},
warning_no_sign_in_identifier:
'O usuário precisa ter pelo menos um dos identificadores de login (nome de usuário, e-mail, número de telefone ou social) para fazer login. Tem certeza de que deseja continuar?',
/** UNTRANSLATED */
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);

View file

@ -23,6 +23,8 @@ const user_details = {
tab_roles: 'Funções',
tab_logs: 'Registos do utilizador',
/** UNTRANSLATED */
tab_organizations: 'Organizations',
/** UNTRANSLATED */
authentication: 'Authentication',
authentication_description:
'Cada utilizador tem um perfil que contém todas as informações do utilizador. Consiste em dados básicos, identidades sociais e dados personalizados.',
@ -102,6 +104,9 @@ const user_details = {
},
warning_no_sign_in_identifier:
'O utilizador precisa de ter pelo menos um dos identificadores de início de sessão (nome de utilizador, e-mail, número de telefone ou redes sociais) para iniciar sessão. Tem a certeza de que quer continuar?',
/** UNTRANSLATED */
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);

View file

@ -21,6 +21,8 @@ const user_details = {
tab_roles: 'Роли',
tab_logs: 'Журналы пользователя',
/** UNTRANSLATED */
tab_organizations: 'Organizations',
/** UNTRANSLATED */
authentication: 'Authentication',
authentication_description:
'У каждого пользователя есть профиль, содержащий всю информацию о пользователе. Он состоит из основных данных, социальных идентификаторов и пользовательских данных.',
@ -100,6 +102,9 @@ const user_details = {
},
warning_no_sign_in_identifier:
'Пользователь должен иметь хотя бы один из идентификаторов входа (имя пользователя, электронная почта, номер телефона или социальная сеть), чтобы войти. Вы уверены, что хотите продолжить?',
/** UNTRANSLATED */
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);

View file

@ -21,6 +21,8 @@ const user_details = {
tab_roles: 'Roller',
tab_logs: 'Kullanıcı kayıtları',
/** UNTRANSLATED */
tab_organizations: 'Organizations',
/** UNTRANSLATED */
authentication: 'Authentication',
authentication_description:
'Her kullanıcının, temel veriler, sosyal kimlikler ve özel verilerden oluşan tüm kullanıcı bilgilerini içeren bir profil vardır.',
@ -101,6 +103,9 @@ const user_details = {
},
warning_no_sign_in_identifier:
'Kullanıcının giriş yapmak için en az bir oturum açma kimliği (kullanıcı adı, e-posta, telefon numarası, veya sosyal) olması gerekiyor. Devam etmek istediğinizden emin misiniz?',
/** UNTRANSLATED */
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);

View file

@ -21,6 +21,8 @@ const user_details = {
tab_roles: '角色',
tab_logs: '用户日志',
/** UNTRANSLATED */
tab_organizations: 'Organizations',
/** UNTRANSLATED */
authentication: 'Authentication',
authentication_description:
'每个用户都有一个包含所有用户信息的个人资料。它由基本数据、社交身份和自定义数据组成。',
@ -95,6 +97,9 @@ const user_details = {
},
warning_no_sign_in_identifier:
'用户需要至少拥有一个登录标识(用户名、邮箱、手机号或社交账户)才能登录。确定要继续吗?',
/** UNTRANSLATED */
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);

View file

@ -21,6 +21,8 @@ const user_details = {
tab_roles: '角色',
tab_logs: '用戶日誌',
/** UNTRANSLATED */
tab_organizations: 'Organizations',
/** UNTRANSLATED */
authentication: 'Authentication',
authentication_description:
'每個用戶都有一個包含所有用戶信息的個人資料。它由基本數據、社交身份和自定義數據組成。',
@ -95,6 +97,9 @@ const user_details = {
},
warning_no_sign_in_identifier:
'用戶需要至少擁有一個登錄標識(用戶名、電子郵件、電話號碼或社交帳號)才能登錄。確定要繼續嗎?',
/** UNTRANSLATED */
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);

View file

@ -21,6 +21,8 @@ const user_details = {
tab_roles: '角色',
tab_logs: '用戶日誌',
/** UNTRANSLATED */
tab_organizations: 'Organizations',
/** UNTRANSLATED */
authentication: 'Authentication',
authentication_description:
'每個用戶都有一個包含所有用戶資訊的個人資料。它由基本數據、社交身份和自定義數據組成。',
@ -95,6 +97,9 @@ const user_details = {
},
warning_no_sign_in_identifier:
'使用者需要至少擁有一個登入標識(使用者名稱、電子郵件、電話號碼或社交帳號)才能登入。確定要繼續嗎?',
/** UNTRANSLATED */
organization_roles_tooltip:
'Organization roles assigned to the current user in this organization.',
};
export default Object.freeze(user_details);