Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/org-invitation-error-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@logto/core': minor

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Version the phrases package with its new error keys

For npm consumers of @logto/phrases, this changeset versions only @logto/core, so changeset version leaves the publishable phrases package at its existing version and the repository's pnpm -r publish invocation has no --force; pnpm publish --help confirms that already-registered package versions are processed only with that flag. Consequently, the new locale resources and exported LogtoErrorCode members will not be published; add an @logto/phrases patch entry to the changeset.

AGENTS.md reference: AGENTS.md:L19-L21

Useful? React with 👍 / 👎.

---
Comment thread
Kathircpe marked this conversation as resolved.

use dedicated i18n error codes for organization invitation validation errors

Organization invitation creation and status update errors previously returned the generic `request.invalid_input` code with English-only `details`. They now return specific, localized codes under the `organization` namespace: `invitee_already_member`, `expires_at_in_future`, `invitation_status_not_changeable`, `accepted_user_id_required`, and `accepted_user_email_mismatch`, so clients can reliably distinguish each failure case and messages render in the user's language.
11 changes: 4 additions & 7 deletions packages/core/src/libraries/organization-invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,7 @@ export class OrganizationInvitationLibrary {
if (await this.queries.organizations.relations.users.isMember(organizationId, invitee)) {
throw new RequestError({
status: 422,
code: 'request.invalid_input',
details: 'The invitee is already a member of the organization.',
code: 'organization.invitee_already_member',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the released validation error codes

For existing Management API clients that branch on request.invalid_input for these established 400/422 responses, replacing the code with organization.* removes a previously accepted error semantic and breaks their handling. Unless an explicit product decision approved that compatibility break, retain the legacy code and expose any finer-grained discriminator in a backward-compatible way; the same issue applies to all five replacements in this commit.

AGENTS.md reference: AGENTS.md:L37-L37

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These five replacements remove error codes that released endpoints have been returning, so any client branching on request.invalid_input for these 400/422 responses (which is what the integration tests did before this PR) silently stops matching. The changeset describes the new codes but not the removal of the old one. If the error code is not part of the stable contract, state that in the changeset; otherwise this needs an explicit product decision. Same for the other four replacements.

});
}

Expand Down Expand Up @@ -152,7 +151,7 @@ export class OrganizationInvitationLibrary {
status: OrganizationInvitationStatus.Accepted,
acceptedUserId: string
): Promise<OrganizationInvitationEntity>;
// TODO: Error i18n

async updateStatus(
Comment thread
Kathircpe marked this conversation as resolved.
id: string,
status: OrganizationInvitationStatus,
Expand All @@ -163,8 +162,7 @@ export class OrganizationInvitationLibrary {
if (endingStatuses.includes(entity.status)) {
throw new RequestError({
status: 422,
code: 'request.invalid_input',
details: 'The status of the invitation cannot be changed anymore.',
code: 'organization.invitation_status_not_changeable',
});
}

Expand All @@ -184,8 +182,7 @@ export class OrganizationInvitationLibrary {
if (user.primaryEmail?.toLowerCase() !== entity.invitee.toLowerCase()) {
throw new RequestError({
status: 422,
code: 'request.invalid_input',
details: 'The accepted user must have the same email as the invitee.',
code: 'organization.accepted_user_email_mismatch',
});
}

Expand Down
7 changes: 2 additions & 5 deletions packages/core/src/routes/organization-invitation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,7 @@ export default function organizationInvitationRoutes<T extends ManagementApiRout
assertThat(
body.expiresAt > Date.now(),
new RequestError({
code: 'request.invalid_input',
details: 'The value of `expiresAt` must be in the future.',
code: 'organization.expires_at_in_future',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the new invitation error codes in OpenAPI

When Management API consumers consult or generate clients from packages/core/src/routes/organization-invitation/index.openapi.json, they still see only the existing generic response descriptions and cannot discover any of the five newly returned codes. Since this change intentionally makes those codes client-distinguishable API behavior, update the corresponding 400/422 response documentation in the same change.

AGENTS.md reference: AGENTS.md:L39-L39

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 400/422 descriptions in index.openapi.json still do not name any of the new codes, so they are not discoverable in the generated API reference (openapi.logto.io). Since the point of this change is that clients can distinguish these failures, document the codes in those response descriptions in the same PR.

})
);
Comment thread
Kathircpe marked this conversation as resolved.

Expand Down Expand Up @@ -149,13 +148,11 @@ export default function organizationInvitationRoutes<T extends ManagementApiRout
return next();
}

// TODO: Error i18n
assertThat(
acceptedUserId,
new RequestError({
status: 422,
code: 'request.invalid_input',
details: 'The `acceptedUserId` is required when accepting an invitation.',
code: 'organization.accepted_user_id_required',
})
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ describe('organization invitation creation', () => {
})
.catch((error: unknown) => error);

await expectErrorResponse(error, 400, 'request.invalid_input');
await expectErrorResponse(error, 400, 'organization.expires_at_in_future');
});

it('should not be able to create invitations if the invitee is already a member of the organization', async () => {
Expand All @@ -172,7 +172,7 @@ describe('organization invitation creation', () => {
})
.catch((error: unknown) => error);

await expectErrorResponse(error, 422, 'request.invalid_input');
await expectErrorResponse(error, 422, 'organization.invitee_already_member');
});

it('should not be able to create invitations with an invalid email', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe('organization invitation status update', () => {
const error = await invitationApi
.updateStatus(invitation.id, OrganizationInvitationStatus.Accepted)
.catch((error: unknown) => error);
await expectErrorResponse(error, 422, 'request.invalid_input');
await expectErrorResponse(error, 422, 'organization.accepted_user_id_required');
});

it('should be able to accept an invitation', async () => {
Expand Down Expand Up @@ -128,7 +128,7 @@ describe('organization invitation status update', () => {
.updateStatus(invitation.id, OrganizationInvitationStatus.Accepted, user.id)
.catch((error: unknown) => error);

await expectErrorResponse(error, 422, 'request.invalid_input');
await expectErrorResponse(error, 422, 'organization.accepted_user_email_mismatch');
});

it('should not be able to accept an invitation with an invalid user id', async () => {
Expand Down Expand Up @@ -162,6 +162,27 @@ describe('organization invitation status update', () => {
.updateStatus(invitation.id, OrganizationInvitationStatus.Accepted)
.catch((error: unknown) => error);

await expectErrorResponse(error, 422, 'request.invalid_input');
await expectErrorResponse(error, 422, 'organization.accepted_user_id_required');
});

it('should not be able to update the status of an accepted invitation', async () => {
const organization = await organizationApi.create({ name: 'test' });
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
});
expect(invitation.status).toBe('Pending');

const user = await userApi.create({
primaryEmail: invitation.invitee,
});
await invitationApi.updateStatus(invitation.id, OrganizationInvitationStatus.Accepted, user.id);

const error = await invitationApi
.updateStatus(invitation.id, OrganizationInvitationStatus.Revoked)
.catch((error: unknown) => error);

await expectErrorResponse(error, 422, 'organization.invitation_status_not_changeable');
});
});
6 changes: 6 additions & 0 deletions packages/phrases/src/locales/ar/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ const organization = {
require_membership: 'يجب أن يكون المستخدم عضوًا في المنظمة للمتابعة.',
role_names_not_found:
'تم اكتشاف أسماء أدوار غير صالحة: {{names,list(type:conjunction)}}. يُرجى إنشاء هذه الأدوار أولاً قبل المتابعة.',
invitation_status_not_changeable: 'لا يمكن تغيير حالة الدعوة بعد الآن.',
accepted_user_id_required: '`acceptedUserId` مطلوب عند قبول دعوة.',
invitee_already_member: 'المدعو عضو بالفعل في المنظمة.',
accepted_user_email_mismatch:
'يجب أن يكون للمستخدم الذي يقبل الدعوة نفس البريد الإلكتروني الخاص بالمدعو.',
expires_at_in_future: 'يجب أن تكون قيمة `expiresAt` في المستقبل.',
};

export default Object.freeze(organization);
6 changes: 6 additions & 0 deletions packages/phrases/src/locales/de/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ const organization = {
require_membership: 'Der Benutzer muss Mitglied der Organisation sein, um fortzufahren.',
role_names_not_found:
'Ungültige Rollennamen erkannt: {{names,list(type:conjunction)}}. Bitte erstelle diese Rollen zuerst, bevor du fortfährst.',
invitation_status_not_changeable: 'Der Status der Einladung kann nicht mehr geändert werden.',
accepted_user_id_required: 'Die `acceptedUserId` ist beim Annehmen einer Einladung erforderlich.',
invitee_already_member: 'Der Eingeladene ist bereits Mitglied der Organisation.',
accepted_user_email_mismatch:
'Der annehmende Benutzer muss dieselbe E-Mail-Adresse wie der Eingeladene haben.',
expires_at_in_future: 'Der Wert von `expiresAt` muss in der Zukunft liegen.',
};

export default Object.freeze(organization);
5 changes: 5 additions & 0 deletions packages/phrases/src/locales/en/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ const organizations = {
require_membership: 'The user must be a member of the organization to proceed.',
role_names_not_found:
'Invalid role names detected: {{names,list(type:conjunction)}}. Please create these roles first before proceeding.',
invitation_status_not_changeable: 'The status of the invitation cannot be changed anymore.',
accepted_user_id_required: 'The `acceptedUserId` is required when accepting an invitation.',
invitee_already_member: 'The invitee is already a member of the organization.',
accepted_user_email_mismatch: 'The accepted user must have the same email as the invitee.',
expires_at_in_future: 'The value of `expiresAt` must be in the future.',
};

export default Object.freeze(organizations);
6 changes: 6 additions & 0 deletions packages/phrases/src/locales/es/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ const organization = {
require_membership: 'El usuario debe ser miembro de la organización para continuar.',
role_names_not_found:
'Nombres de roles no válidos detectados: {{names,list(type:conjunction)}}. Por favor, crea estos roles primero antes de continuar.',
invitation_status_not_changeable: 'El estado de la invitación ya no se puede cambiar.',
accepted_user_id_required: 'Se requiere `acceptedUserId` al aceptar una invitación.',
invitee_already_member: 'El invitado ya es miembro de la organización.',
accepted_user_email_mismatch:
'El usuario que acepta debe tener el mismo correo electrónico que el invitado.',
expires_at_in_future: 'El valor de `expiresAt` debe estar en el futuro.',
};

export default Object.freeze(organization);
5 changes: 5 additions & 0 deletions packages/phrases/src/locales/fa-ir/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ const organizations = {
require_membership: 'کاربر باید عضو سازمان باشد تا ادامه دهد.',
role_names_not_found:
'نام‌های نقش نامعتبر شناسایی شد: {{names,list(type:conjunction)}}. لطفاً ابتدا این نقش‌ها را ایجاد کنید.',
invitation_status_not_changeable: 'وضعیت دعوت‌نامه دیگر قابل تغییر نیست.',
accepted_user_id_required: 'هنگام پذیرش دعوت‌نامه، `acceptedUserId` الزامی است.',
invitee_already_member: 'دعوت‌شونده در حال حاضر عضو سازمان است.',
accepted_user_email_mismatch: 'کاربر پذیرنده باید همان ایمیل دعوت‌شونده را داشته باشد.',
expires_at_in_future: 'مقدار `expiresAt` باید در آینده باشد.',
};

export default Object.freeze(organizations);
7 changes: 7 additions & 0 deletions packages/phrases/src/locales/fr/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ const organization = {
require_membership: "L'utilisateur doit être membre de l'organisation pour continuer.",
role_names_not_found:
"Des noms de rôles non valides ont été détectés : {{names,list(type:conjunction)}}. Veuillez créer ces rôles d'abord avant de continuer.",
invitation_status_not_changeable: "Le statut de l'invitation ne peut plus être modifié.",
accepted_user_id_required:
"L'`acceptedUserId` est requis lors de l'acceptation d'une invitation.",
invitee_already_member: "L'invité est déjà membre de l'organisation.",
accepted_user_email_mismatch:
"L'utilisateur qui accepte doit avoir la même adresse e-mail que l'invité.",
expires_at_in_future: 'La valeur de `expiresAt` doit être dans le futur.',
};

export default Object.freeze(organization);
5 changes: 5 additions & 0 deletions packages/phrases/src/locales/it/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ const organization = {
require_membership: "L'utente deve essere un membro dell'organizzazione per procedere.",
role_names_not_found:
'Nomi di ruoli non validi rilevati: {{names,list(type:conjunction)}}. Si prega di creare prima questi ruoli prima di procedere.',
invitation_status_not_changeable: "Lo stato dell'invito non può più essere modificato.",
accepted_user_id_required: "L'`acceptedUserId` è obbligatorio quando si accetta un invito.",
invitee_already_member: "L'invitato è già membro dell'organizzazione.",
accepted_user_email_mismatch: "L'utente che accetta deve avere la stessa email dell'invitato.",
expires_at_in_future: 'Il valore di `expiresAt` deve essere nel futuro.',
};

export default Object.freeze(organization);
6 changes: 6 additions & 0 deletions packages/phrases/src/locales/ja/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ const organization = {
require_membership: 'ユーザーは組織のメンバーである必要があります。',
role_names_not_found:
'無効なロール名が検出されました:{{names,list(type:conjunction)}}。これらのロールを作成してから次へ進んでください。',
invitation_status_not_changeable: '招待のステータスはこれ以上変更できません。',
accepted_user_id_required: '招待を受け入れるには `acceptedUserId` が必要です。',
invitee_already_member: '招待されたユーザーはすでに組織のメンバーです。',
accepted_user_email_mismatch:
'受け入れるユーザーは招待されたユーザーと同じメールアドレスである必要があります。',
expires_at_in_future: '`expiresAt` の値は将来である必要があります。',
};

export default Object.freeze(organization);
6 changes: 6 additions & 0 deletions packages/phrases/src/locales/ko/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ const organization = {
require_membership: '사용자는 조직의 구성원이어야 합니다.',
role_names_not_found:
'유효하지 않은 역할 이름이 감지되었습니다: {{names,list(type:conjunction)}}. 계속하기 전에 먼저 이 역할들을 생성해 주세요.',
invitation_status_not_changeable: '초대 상태는 더 이상 변경할 수 없습니다.',
accepted_user_id_required: '초대를 수락하려면 `acceptedUserId`가 필요합니다.',
invitee_already_member: '초대받은 사용자는 이미 조직의 구성원입니다.',
accepted_user_email_mismatch:
'수락하는 사용자는 초대받은 사용자와 동일한 이메일을 가져야 합니다.',
expires_at_in_future: '`expiresAt` 값은 미래여야 합니다.',
};

export default Object.freeze(organization);
6 changes: 6 additions & 0 deletions packages/phrases/src/locales/pl-pl/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ const organization = {
require_membership: 'Użytkownik musi być członkiem organizacji, aby kontynuować.',
role_names_not_found:
'检测到无效的角色名称:{{names,list(type:conjunction)}}。请先创建这些角色,然后再继续。',
invitation_status_not_changeable: 'Status zaproszenia nie może być już zmieniony.',
accepted_user_id_required: '`acceptedUserId` jest wymagane podczas akceptowania zaproszenia.',
invitee_already_member: 'Zaproszony użytkownik jest już członkiem organizacji.',
accepted_user_email_mismatch:
'Akceptujący użytkownik musi mieć ten sam adres e-mail co zaproszony.',
expires_at_in_future: 'Wartość `expiresAt` musi być w przyszłości.',
};

export default Object.freeze(organization);
5 changes: 5 additions & 0 deletions packages/phrases/src/locales/pt-br/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ const organization = {
require_membership: 'O usuário deve ser um membro da organização para continuar.',
role_names_not_found:
'检测到无效的角色名称:{{names,list(type:conjunction)}}。请先创建这些角色然后再继续操作。',
invitation_status_not_changeable: 'O status do convite não pode mais ser alterado.',
accepted_user_id_required: 'O `acceptedUserId` é obrigatório ao aceitar um convite.',
invitee_already_member: 'O convidado já é membro da organização.',
accepted_user_email_mismatch: 'O usuário que aceita deve ter o mesmo e-mail do convidado.',
expires_at_in_future: 'O valor de `expiresAt` deve estar no futuro.',
};

export default Object.freeze(organization);
5 changes: 5 additions & 0 deletions packages/phrases/src/locales/pt-pt/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ const organization = {
require_membership: 'O utilizador deve ser membro da organização para avançar.',
role_names_not_found:
'检测到无效的角色名称:{{names,list(type:conjunction)}}。请先创建这些角色然后再继续。',
invitation_status_not_changeable: 'O estado do convite já não pode ser alterado.',
accepted_user_id_required: 'O `acceptedUserId` é obrigatório ao aceitar um convite.',
invitee_already_member: 'O convidado já é membro da organização.',
accepted_user_email_mismatch: 'O utilizador que aceita deve ter o mesmo e-mail do convidado.',
expires_at_in_future: 'O valor de `expiresAt` deve estar no futuro.',
};

export default Object.freeze(organization);
6 changes: 6 additions & 0 deletions packages/phrases/src/locales/ru/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ const organization = {
require_membership: 'Пользователь должен быть участником организации, чтобы продолжить.',
role_names_not_found:
'Обнаружены недопустимые имена ролей: {{names,list(type:conjunction)}}. Пожалуйста, сначала создайте эти роли, прежде чем продолжить.',
invitation_status_not_changeable: 'Статус приглашения больше не может быть изменен.',
accepted_user_id_required: '`acceptedUserId` обязателен при принятии приглашения.',
invitee_already_member: 'Приглашенный уже является участником организации.',
accepted_user_email_mismatch:
'Принимающий пользователь должен иметь тот же адрес электронной почты, что и приглашенный.',
expires_at_in_future: 'Значение `expiresAt` должно быть в будущем.',
};

export default Object.freeze(organization);
5 changes: 5 additions & 0 deletions packages/phrases/src/locales/th/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ const organization = {
require_membership: 'ผู้ใช้จะต้องเป็นสมาชิกขององค์กรก่อนจึงจะดำเนินการต่อได้',
role_names_not_found:
'ตรวจพบชื่อบทบาทที่ไม่ถูกต้อง: {{names,list(type:conjunction)}} กรุณาสร้างบทบาทเหล่านี้ก่อนจึงจะดำเนินการต่อได้',
invitation_status_not_changeable: 'ไม่สามารถเปลี่ยนสถานะคำเชิญได้อีกต่อไป',
accepted_user_id_required: 'จำเป็นต้องระบุ `acceptedUserId` เมื่อยอมรับคำเชิญ',
invitee_already_member: 'ผู้ที่ได้รับเชิญเป็นสมาชิกขององค์กรอยู่แล้ว',
accepted_user_email_mismatch: 'ผู้ใช้ที่ยอมรับต้องมีอีเมลเดียวกับผู้ที่ได้รับเชิญ',
expires_at_in_future: 'ค่าของ `expiresAt` ต้องอยู่ในอนาคต',
};

export default Object.freeze(organization);
6 changes: 6 additions & 0 deletions packages/phrases/src/locales/tr-tr/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ const organization = {
require_membership: 'Kullanıcının devam etmek için organizasyonun bir üyesi olması gerekir.',
role_names_not_found:
'检测到无效的角色名称:{{names,list(type:conjunction)}}。请先创建这些角色,然后再继续。',
invitation_status_not_changeable: 'Davetin durumu artık değiştirilemez.',
accepted_user_id_required: 'Bir daveti kabul ederken `acceptedUserId` gerekli.',
invitee_already_member: 'Davet edilen kişi zaten organizasyonun bir üyesi.',
accepted_user_email_mismatch:
'Kabul eden kullanıcının, davet edilenle aynı e-postaya sahip olması gerekir.',
expires_at_in_future: '`expiresAt` değeri gelecekte olmalıdır.',
};

export default Object.freeze(organization);
5 changes: 5 additions & 0 deletions packages/phrases/src/locales/zh-cn/errors/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ const organization = {
require_membership: '用户必须是组织的成员才能继续。',
role_names_not_found:
'检测到无效的角色名称:{{names,list(type:conjunction)}}。请先创建这些角色,然后再继续。',
invitation_status_not_changeable: '邀请状态不能再更改。',
accepted_user_id_required: '接受邀请时必须提供 `acceptedUserId`。',
invitee_already_member: '受邀者已经是该组织的成员。',
accepted_user_email_mismatch: '接受邀请的用户必须与受邀者邮箱一致。',
expires_at_in_future: '`expiresAt` 的值必须在未来。',
};

export default Object.freeze(organization);
Loading
Loading