mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
feat: support organization custom data (#5785)
* feat: support organization custom data * chore: update changeset
This commit is contained in:
parent
397dfcdf92
commit
e8c41b1644
21 changed files with 210 additions and 10 deletions
11
.changeset/healthy-knives-draw.md
Normal file
11
.changeset/healthy-knives-draw.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
"@logto/schemas": minor
|
||||
"@logto/core": minor
|
||||
---
|
||||
|
||||
support organization custom data
|
||||
|
||||
Now you can save additional data associated with the organization with the organization-level `customData` field by:
|
||||
|
||||
- Edit in the Console organization details page.
|
||||
- Specify `customData` field when using organization Management APIs.
|
|
@ -1,5 +1,6 @@
|
|||
import { type Organization } from '@logto/schemas';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
|
@ -7,6 +8,7 @@ import { useOutletContext } from 'react-router-dom';
|
|||
import DetailsForm from '@/components/DetailsForm';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
import CodeEditor from '@/ds-components/CodeEditor';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import TextInput from '@/ds-components/TextInput';
|
||||
import useApi from '@/hooks/use-api';
|
||||
|
@ -14,29 +16,50 @@ import { trySubmitSafe } from '@/utils/form';
|
|||
|
||||
import { type OrganizationDetailsOutletContext } from '../types';
|
||||
|
||||
type FormData = Partial<Omit<Organization, 'customData'> & { customData: string }>;
|
||||
|
||||
const isJsonObject = (value: string) => {
|
||||
const parsed = trySafe<unknown>(() => JSON.parse(value));
|
||||
return Boolean(parsed && typeof parsed === 'object');
|
||||
};
|
||||
|
||||
const normalizeData = (data: Organization): FormData => ({
|
||||
...data,
|
||||
customData: JSON.stringify(data.customData, undefined, 2),
|
||||
});
|
||||
|
||||
const assembleData = (data: FormData): Partial<Organization> => ({
|
||||
...data,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
customData: JSON.parse(data.customData ?? '{}'),
|
||||
});
|
||||
|
||||
function Settings() {
|
||||
const { isDeleting, data, onUpdated } = useOutletContext<OrganizationDetailsOutletContext>();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isDirty, isSubmitting, errors },
|
||||
} = useForm<Partial<Organization>>({
|
||||
defaultValues: data,
|
||||
} = useForm<FormData>({
|
||||
defaultValues: normalizeData(data),
|
||||
});
|
||||
const api = useApi();
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
trySubmitSafe(async (json) => {
|
||||
trySubmitSafe(async (data) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedData = await api
|
||||
.patch(`api/organizations/${data.id}`, { json })
|
||||
.patch(`api/organizations/${data.id}`, {
|
||||
json: assembleData(data),
|
||||
})
|
||||
.json<Organization>();
|
||||
reset(updatedData);
|
||||
reset(normalizeData(updatedData));
|
||||
toast.success(t('general.saved'));
|
||||
onUpdated(updatedData);
|
||||
})
|
||||
|
@ -66,6 +89,22 @@ function Settings() {
|
|||
{...register('description')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
title="organization_details.custom_data"
|
||||
tip={t('organization_details.custom_data_tip')}
|
||||
>
|
||||
<Controller
|
||||
name="customData"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value) =>
|
||||
isJsonObject(value ?? '') ? true : t('organization_details.invalid_json_object'),
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<CodeEditor language="json" {...field} error={errors.customData?.message} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormCard>
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
|
||||
</DetailsForm>
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
type Organization,
|
||||
type OrganizationRoleWithScopes,
|
||||
type OrganizationInvitationEntity,
|
||||
type JsonObject,
|
||||
} from '@logto/schemas';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
|
||||
|
@ -122,7 +123,11 @@ export class OrganizationApiTest extends OrganizationApi {
|
|||
return this.#organizations;
|
||||
}
|
||||
|
||||
override async create(data: { name: string; description?: string }): Promise<Organization> {
|
||||
override async create(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
customData?: JsonObject;
|
||||
}): Promise<Organization> {
|
||||
const created = await super.create(data);
|
||||
this.organizations.push(created);
|
||||
return created;
|
||||
|
|
|
@ -13,15 +13,23 @@ describe('organization APIs', () => {
|
|||
});
|
||||
|
||||
it('should get organizations successfully', async () => {
|
||||
await organizationApi.create({ name: 'test', description: 'A test organization.' });
|
||||
await organizationApi.create({
|
||||
name: 'test',
|
||||
description: 'A test organization.',
|
||||
customData: { foo: 'bar' },
|
||||
});
|
||||
await organizationApi.create({ name: 'test2' });
|
||||
const organizations = await organizationApi.getList();
|
||||
|
||||
expect(organizations).toContainEqual(
|
||||
expect.objectContaining({ name: 'test', description: 'A test organization.' })
|
||||
expect.objectContaining({
|
||||
name: 'test',
|
||||
description: 'A test organization.',
|
||||
customData: { foo: 'bar' },
|
||||
})
|
||||
);
|
||||
expect(organizations).toContainEqual(
|
||||
expect.objectContaining({ name: 'test2', description: null })
|
||||
expect.objectContaining({ name: 'test2', description: null, customData: {} })
|
||||
);
|
||||
for (const organization of organizations) {
|
||||
expect(organization).not.toHaveProperty('usersCount');
|
||||
|
@ -29,6 +37,14 @@ describe('organization APIs', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('should fail when input data is malformed', async () => {
|
||||
const response = await organizationApi
|
||||
// @ts-expect-error intended to test invalid input
|
||||
.create({ name: 'a', customData: 'b' })
|
||||
.catch((error: unknown) => error);
|
||||
expect(response instanceof HTTPError && response.response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should get organizations with featured users', async () => {
|
||||
const [organization1, organization2] = await Promise.all([
|
||||
organizationApi.create({ name: 'test' }),
|
||||
|
|
|
@ -22,6 +22,13 @@ const organization_details = {
|
|||
'Wenn entfernt, verliert der Benutzer seine Mitgliedschaft und Rollen in dieser Organisation. Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
search_user_placeholder: 'Nach Name, E-Mail, Telefon oder Benutzer-ID suchen',
|
||||
at_least_one_user: 'Mindestens ein Benutzer ist erforderlich.',
|
||||
/** UNTRANSLATED */
|
||||
custom_data: 'Custom data',
|
||||
/** UNTRANSLATED */
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -22,6 +22,10 @@ const organization_details = {
|
|||
'Once removed, the user will lose their membership and roles in this organization. This action cannot be undone.',
|
||||
search_user_placeholder: 'Search by name, email, phone or user ID',
|
||||
at_least_one_user: 'At least one user is required.',
|
||||
custom_data: 'Custom data',
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -22,6 +22,13 @@ const organization_details = {
|
|||
'Una vez eliminado, el usuario perderá su membresía y roles en esta organización. Esta acción no se puede deshacer.',
|
||||
search_user_placeholder: 'Buscar por nombre, correo electrónico, teléfono o ID de usuario',
|
||||
at_least_one_user: 'Se requiere al menos un usuario.',
|
||||
/** UNTRANSLATED */
|
||||
custom_data: 'Custom data',
|
||||
/** UNTRANSLATED */
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -22,6 +22,13 @@ const organization_details = {
|
|||
"Une fois retiré, l'utilisateur perdra son adhésion et ses rôles dans cette organisation. Cette action ne peut pas être annulée.",
|
||||
search_user_placeholder: "Rechercher par nom, e-mail, téléphone ou identifiant d'utilisateur",
|
||||
at_least_one_user: 'Au moins un utilisateur est requis.',
|
||||
/** UNTRANSLATED */
|
||||
custom_data: 'Custom data',
|
||||
/** UNTRANSLATED */
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -22,6 +22,13 @@ const organization_details = {
|
|||
"Una volta rimosso, l'utente perderà la sua iscrizione e i ruoli in questa organizzazione. Quest'azione non può essere annullata.",
|
||||
search_user_placeholder: 'Cerca per nome, email, telefono o ID utente',
|
||||
at_least_one_user: 'È richiesto almeno un utente.',
|
||||
/** UNTRANSLATED */
|
||||
custom_data: 'Custom data',
|
||||
/** UNTRANSLATED */
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -22,6 +22,13 @@ const organization_details = {
|
|||
'削除すると、ユーザーは組織内のメンバーシップとロールを失います。この操作は元に戻せません。',
|
||||
search_user_placeholder: '名前、メール、電話番号、またはユーザーIDで検索',
|
||||
at_least_one_user: '少なくとも1人のユーザーが必要です。',
|
||||
/** UNTRANSLATED */
|
||||
custom_data: 'Custom data',
|
||||
/** UNTRANSLATED */
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -22,6 +22,13 @@ const organization_details = {
|
|||
'제거하면 사용자가 이 조직에서 멤버십과 역할을 잃습니다. 이 작업은 취소할 수 없습니다.',
|
||||
search_user_placeholder: '이름, 이메일, 전화 또는 사용자 ID로 검색',
|
||||
at_least_one_user: '최소한 한 명의 사용자가 필요합니다.',
|
||||
/** UNTRANSLATED */
|
||||
custom_data: 'Custom data',
|
||||
/** UNTRANSLATED */
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -23,6 +23,13 @@ const organization_details = {
|
|||
search_user_placeholder:
|
||||
'Wyszukaj według nazwy, adresu e-mail, numeru telefonu lub identyfikatora użytkownika',
|
||||
at_least_one_user: 'Wymagany jest co najmniej jeden użytkownik.',
|
||||
/** UNTRANSLATED */
|
||||
custom_data: 'Custom data',
|
||||
/** UNTRANSLATED */
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -22,6 +22,13 @@ const organization_details = {
|
|||
'Uma vez removido, o usuário perderá sua associação e cargos nesta organização. Essa ação não pode ser desfeita.',
|
||||
search_user_placeholder: 'Pesquisar por nome, e-mail, telefone ou ID do usuário',
|
||||
at_least_one_user: 'Pelo menos um usuário é necessário.',
|
||||
/** UNTRANSLATED */
|
||||
custom_data: 'Custom data',
|
||||
/** UNTRANSLATED */
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -22,6 +22,13 @@ const organization_details = {
|
|||
'Uma vez removido, o utilizador perderá a sua adesão e funções nesta organização. Esta ação não pode ser desfeita.',
|
||||
search_user_placeholder: 'Pesquisar por nome, email, telefone ou ID de utilizador',
|
||||
at_least_one_user: 'Pelo menos um utilizador é necessário.',
|
||||
/** UNTRANSLATED */
|
||||
custom_data: 'Custom data',
|
||||
/** UNTRANSLATED */
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -23,6 +23,13 @@ const organization_details = {
|
|||
search_user_placeholder:
|
||||
'Поиск по имени, электронной почте, телефону или идентификатору пользователя',
|
||||
at_least_one_user: 'Необходимо указать хотя бы одного пользователя.',
|
||||
/** UNTRANSLATED */
|
||||
custom_data: 'Custom data',
|
||||
/** UNTRANSLATED */
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -22,6 +22,13 @@ const organization_details = {
|
|||
'Kaldırıldığında, kullanıcı bu kuruluşta üyeliğini ve rollerini kaybedecek. Bu işlem geri alınamaz.',
|
||||
search_user_placeholder: 'İsim, e-posta, telefon veya kullanıcı kimliği ile ara',
|
||||
at_least_one_user: 'En az bir kullanıcı gereklidir.',
|
||||
/** UNTRANSLATED */
|
||||
custom_data: 'Custom data',
|
||||
/** UNTRANSLATED */
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -21,6 +21,13 @@ const organization_details = {
|
|||
'一旦移除,用户将失去他们在这个机构中的成员资格和角色。此操作将无法撤销。',
|
||||
search_user_placeholder: '按名称、电子邮件、电话或用户ID搜索',
|
||||
at_least_one_user: '至少需要一个用户。',
|
||||
/** UNTRANSLATED */
|
||||
custom_data: 'Custom data',
|
||||
/** UNTRANSLATED */
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -20,6 +20,13 @@ const organization_details = {
|
|||
'移除後,使用者將失去他們在此組織的成員資格和角色。此操作無法撤銷。',
|
||||
search_user_placeholder: '按姓名、電子郵件、電話或使用者 ID 搜尋',
|
||||
at_least_one_user: '至少需要一名使用者。',
|
||||
/** UNTRANSLATED */
|
||||
custom_data: 'Custom data',
|
||||
/** UNTRANSLATED */
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -20,6 +20,13 @@ const organization_details = {
|
|||
'刪除後,用戶將失去在這個組織中的成員資格和角色。此操作無法撤銷。',
|
||||
search_user_placeholder: '按名稱、電子郵件、電話或用戶ID搜尋',
|
||||
at_least_one_user: '至少需要一個用戶。',
|
||||
/** UNTRANSLATED */
|
||||
custom_data: 'Custom data',
|
||||
/** UNTRANSLATED */
|
||||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
};
|
||||
|
||||
export default Object.freeze(organization_details);
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import { sql } from '@silverhand/slonik';
|
||||
|
||||
import type { AlterationScript } from '../lib/types/alteration.js';
|
||||
|
||||
/** The alteration script to add the `custom_data` field to the `organizations` table. */
|
||||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
await pool.query(
|
||||
sql`
|
||||
alter table organizations
|
||||
add column custom_data jsonb not null default '{}'::jsonb;
|
||||
`
|
||||
);
|
||||
},
|
||||
down: async (pool) => {
|
||||
await pool.query(
|
||||
sql`
|
||||
alter table organizations
|
||||
drop column custom_data;
|
||||
`
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -10,6 +10,8 @@ create table organizations (
|
|||
name varchar(128) not null,
|
||||
/** A brief description of the organization. */
|
||||
description varchar(256),
|
||||
/** Additional data associated with the organization. */
|
||||
custom_data jsonb /* @use JsonObject */ not null default '{}'::jsonb,
|
||||
/** When the organization was created. */
|
||||
created_at timestamptz not null default(now()),
|
||||
primary key (id)
|
||||
|
|
Loading…
Add table
Reference in a new issue