0
Fork 0
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:
Gao Sun 2024-04-25 22:16:59 +08:00 committed by GitHub
parent 397dfcdf92
commit e8c41b1644
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 210 additions and 10 deletions

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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