diff --git a/.changeset/27521-invitation-screens.md b/.changeset/27521-invitation-screens.md new file mode 100644 index 00000000000..4c550d187b5 --- /dev/null +++ b/.changeset/27521-invitation-screens.md @@ -0,0 +1,16 @@ +--- +'@objectstack/platform-objects': patch +'@object-ui/app-shell': patch +'@object-ui/plugin-form': patch +'@object-ui/i18n': patch +'@object-ui/console': patch +--- + +Clause-②: yes (widening: `@object-ui/app-shell` exports `InvitationLinkHint` and `@object-ui/plugin-form` exports `useInitialFieldFocus`; no accept set moves) +Metadata-protocol: none (platform object metadata only, no authorable key: `sys_invitation.status` options gain display labels, with `rejected` read as "Declined"; the role param of the four `invite_user` actions, `sys_member.add_member` and `sys_member.update_member_role` gains one shared `helpText`; `sys_invitation.resend_invitation` gains a `description` and carries its email and role over from the row; `sys_member.update_member_role` gains a `description` naming the member; the `expired` invitations list projects `role`) + +The Invitations screens read more clearly and behave more predictably. Invitation statuses show as labels (Pending, Accepted, Declined, Expired, Canceled) instead of raw values. Every role picker that grants an organization role explains each role on its own line, and Change Role names the member it changes. Resend is a confirmation that names the invitee and keeps the invitation's email and role, instead of an editable form. The workspace's cancel dialog dismisses with "Keep invitation". + +The console's action dialogs check an email or URL param before anything runs and mark a malformed value on its field. They open on their first editable field instead of Cancel, and closing one returns focus to the control that opened it: an action button that was disabled while its action ran, a row menu's trigger, or the first opener when a param dialog hands over to a result dialog. This applies to every action that uses the param, confirm and result dialogs. + +The sign-in page tells a visitor to open their invitation link where sign-up is by invitation only. After accepting an invitation, the page says the user joined the organization and as which role, and Continue enters it. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index a908dc5407a..caf62206427 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -146,6 +146,7 @@ export const enObjects: NonNullable = { }, params: { role: { + helpText: "Owner: full control of the organization, including deleting it.\nAdmin: manages members, invitations, teams and organization settings.\nDelegated Admin: invites people and places them only where an administrator has delegated that.\nMember: works in the organization's apps with the access their permission sets give.\nExternal: someone from outside the organization, such as a supplier or customer, who sees only what is shared with them.", options: { owner: "Owner", admin: "Admin", @@ -585,6 +586,7 @@ export const enObjects: NonNullable = { }, params: { role: { + helpText: "Owner: full control of the organization, including deleting it.\nAdmin: manages members, invitations, teams and organization settings.\nDelegated Admin: invites people and places them only where an administrator has delegated that.\nMember: works in the organization's apps with the access their permission sets give.\nExternal: someone from outside the organization, such as a supplier or customer, who sees only what is shared with them.", options: { owner: "Owner", admin: "Admin", @@ -676,6 +678,7 @@ export const enObjects: NonNullable = { }, params: { role: { + helpText: "Owner: full control of the organization, including deleting it.\nAdmin: manages members, invitations, teams and organization settings.\nDelegated Admin: invites people and places them only where an administrator has delegated that.\nMember: works in the organization's apps with the access their permission sets give.\nExternal: someone from outside the organization, such as a supplier or customer, who sees only what is shared with them.", options: { owner: "Owner", admin: "Admin", @@ -688,13 +691,24 @@ export const enObjects: NonNullable = { }, add_member: { label: "Add Member", - successMessage: "Member added" + successMessage: "Member added", + params: { + role: { + helpText: "Owner: full control of the organization, including deleting it.\nAdmin: manages members, invitations, teams and organization settings.\nDelegated Admin: invites people and places them only where an administrator has delegated that.\nMember: works in the organization's apps with the access their permission sets give.\nExternal: someone from outside the organization, such as a supplier or customer, who sees only what is shared with them." + } + } }, update_member_role: { label: "Change Role", + description: "Choose the new organization role for ${record.user_id?.name ?? 'this member'}.", successMessage: "Member role updated", outcomeMessages: { permission_set_granted: "Member role updated. The new role also granted the permission set ${result.grantedPermissionSet.label}." + }, + params: { + role: { + helpText: "Owner: full control of the organization, including deleting it.\nAdmin: manages members, invitations, teams and organization settings.\nDelegated Admin: invites people and places them only where an administrator has delegated that.\nMember: works in the organization's apps with the access their permission sets give.\nExternal: someone from outside the organization, such as a supplier or customer, who sees only what is shared with them." + } } }, remove_member: { @@ -748,11 +762,11 @@ export const enObjects: NonNullable = { status: { label: "Status", options: { - pending: "pending", - accepted: "accepted", - rejected: "rejected", - expired: "expired", - canceled: "canceled" + pending: "Pending", + accepted: "Accepted", + rejected: "Declined", + expired: "Expired", + canceled: "Canceled" } }, inviter_id: { @@ -816,6 +830,7 @@ export const enObjects: NonNullable = { }, params: { role: { + helpText: "Owner: full control of the organization, including deleting it.\nAdmin: manages members, invitations, teams and organization settings.\nDelegated Admin: invites people and places them only where an administrator has delegated that.\nMember: works in the organization's apps with the access their permission sets give.\nExternal: someone from outside the organization, such as a supplier or customer, who sees only what is shared with them.", options: { owner: "Owner", admin: "Admin", @@ -833,6 +848,7 @@ export const enObjects: NonNullable = { }, resend_invitation: { label: "Resend Invitation", + description: "Resend the invitation to ${record.email}? Its expiry date starts over.", successMessage: "Invitation resent", outcomeMessages: { sent: "Invitation resent to ${result.email}", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 34be274894b..ec43f717095 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -146,6 +146,7 @@ export const esESObjects: NonNullable = { }, params: { role: { + helpText: "Propietario: control total de la organización, incluida su eliminación.\nAdministrador: gestiona los miembros, las invitaciones, los equipos y la configuración de la organización.\nAdministrador delegado: invita a personas y solo las ubica donde un administrador se lo ha delegado.\nMiembro: trabaja en las aplicaciones de la organización con el acceso que le dan sus conjuntos de permisos.\nExterno: alguien ajeno a la organización, como un proveedor o un cliente, que solo ve lo que se comparte con él.", options: { owner: "Propietario", admin: "Administrador", @@ -585,6 +586,7 @@ export const esESObjects: NonNullable = { }, params: { role: { + helpText: "Propietario: control total de la organización, incluida su eliminación.\nAdministrador: gestiona los miembros, las invitaciones, los equipos y la configuración de la organización.\nAdministrador delegado: invita a personas y solo las ubica donde un administrador se lo ha delegado.\nMiembro: trabaja en las aplicaciones de la organización con el acceso que le dan sus conjuntos de permisos.\nExterno: alguien ajeno a la organización, como un proveedor o un cliente, que solo ve lo que se comparte con él.", options: { owner: "Propietario", admin: "Administrador", @@ -676,6 +678,7 @@ export const esESObjects: NonNullable = { }, params: { role: { + helpText: "Propietario: control total de la organización, incluida su eliminación.\nAdministrador: gestiona los miembros, las invitaciones, los equipos y la configuración de la organización.\nAdministrador delegado: invita a personas y solo las ubica donde un administrador se lo ha delegado.\nMiembro: trabaja en las aplicaciones de la organización con el acceso que le dan sus conjuntos de permisos.\nExterno: alguien ajeno a la organización, como un proveedor o un cliente, que solo ve lo que se comparte con él.", options: { owner: "Propietario", admin: "Administrador", @@ -688,13 +691,24 @@ export const esESObjects: NonNullable = { }, add_member: { label: "Añadir miembro", - successMessage: "Miembro añadido" + successMessage: "Miembro añadido", + params: { + role: { + helpText: "Propietario: control total de la organización, incluida su eliminación.\nAdministrador: gestiona los miembros, las invitaciones, los equipos y la configuración de la organización.\nAdministrador delegado: invita a personas y solo las ubica donde un administrador se lo ha delegado.\nMiembro: trabaja en las aplicaciones de la organización con el acceso que le dan sus conjuntos de permisos.\nExterno: alguien ajeno a la organización, como un proveedor o un cliente, que solo ve lo que se comparte con él." + } + } }, update_member_role: { label: "Cambiar rol", + description: "Elija el nuevo rol en la organización para ${record.user_id?.name ?? 'este miembro'}.", successMessage: "Rol del miembro actualizado", outcomeMessages: { permission_set_granted: "Rol del miembro actualizado. El nuevo rol también concedió el conjunto de permisos ${result.grantedPermissionSet.label}." + }, + params: { + role: { + helpText: "Propietario: control total de la organización, incluida su eliminación.\nAdministrador: gestiona los miembros, las invitaciones, los equipos y la configuración de la organización.\nAdministrador delegado: invita a personas y solo las ubica donde un administrador se lo ha delegado.\nMiembro: trabaja en las aplicaciones de la organización con el acceso que le dan sus conjuntos de permisos.\nExterno: alguien ajeno a la organización, como un proveedor o un cliente, que solo ve lo que se comparte con él." + } } }, remove_member: { @@ -816,6 +830,7 @@ export const esESObjects: NonNullable = { }, params: { role: { + helpText: "Propietario: control total de la organización, incluida su eliminación.\nAdministrador: gestiona los miembros, las invitaciones, los equipos y la configuración de la organización.\nAdministrador delegado: invita a personas y solo las ubica donde un administrador se lo ha delegado.\nMiembro: trabaja en las aplicaciones de la organización con el acceso que le dan sus conjuntos de permisos.\nExterno: alguien ajeno a la organización, como un proveedor o un cliente, que solo ve lo que se comparte con él.", options: { owner: "Propietario", admin: "Administrador", @@ -833,6 +848,7 @@ export const esESObjects: NonNullable = { }, resend_invitation: { label: "Reenviar invitación", + description: "¿Reenviar la invitación a ${record.email}? Su plazo de vencimiento vuelve a empezar.", successMessage: "Invitación reenviada", outcomeMessages: { sent: "Invitación reenviada a ${result.email}", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 5862e1df951..450e60ea621 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -146,6 +146,7 @@ export const jaJPObjects: NonNullable = { }, params: { role: { + helpText: "所有者:組織を完全に管理でき、組織の削除もできます。\n管理者:メンバー、招待、チーム、組織の設定を管理します。\n委任管理者:ユーザーを招待し、管理者から委任された範囲にのみ配置できます。\nメンバー:権限セットで付与されたアクセス権で組織のアプリを利用します。\n外部メンバー:仕入先や顧客など組織外のユーザーで、共有されたものだけを閲覧できます。", options: { owner: "所有者", admin: "管理者", @@ -585,6 +586,7 @@ export const jaJPObjects: NonNullable = { }, params: { role: { + helpText: "所有者:組織を完全に管理でき、組織の削除もできます。\n管理者:メンバー、招待、チーム、組織の設定を管理します。\n委任管理者:ユーザーを招待し、管理者から委任された範囲にのみ配置できます。\nメンバー:権限セットで付与されたアクセス権で組織のアプリを利用します。\n外部メンバー:仕入先や顧客など組織外のユーザーで、共有されたものだけを閲覧できます。", options: { owner: "所有者", admin: "管理者", @@ -676,6 +678,7 @@ export const jaJPObjects: NonNullable = { }, params: { role: { + helpText: "所有者:組織を完全に管理でき、組織の削除もできます。\n管理者:メンバー、招待、チーム、組織の設定を管理します。\n委任管理者:ユーザーを招待し、管理者から委任された範囲にのみ配置できます。\nメンバー:権限セットで付与されたアクセス権で組織のアプリを利用します。\n外部メンバー:仕入先や顧客など組織外のユーザーで、共有されたものだけを閲覧できます。", options: { owner: "所有者", admin: "管理者", @@ -688,13 +691,24 @@ export const jaJPObjects: NonNullable = { }, add_member: { label: "メンバーを追加", - successMessage: "メンバーを追加しました" + successMessage: "メンバーを追加しました", + params: { + role: { + helpText: "所有者:組織を完全に管理でき、組織の削除もできます。\n管理者:メンバー、招待、チーム、組織の設定を管理します。\n委任管理者:ユーザーを招待し、管理者から委任された範囲にのみ配置できます。\nメンバー:権限セットで付与されたアクセス権で組織のアプリを利用します。\n外部メンバー:仕入先や顧客など組織外のユーザーで、共有されたものだけを閲覧できます。" + } + } }, update_member_role: { label: "ロールを変更", + description: "${record.user_id?.name ?? 'このメンバー'} の新しい組織ロールを選択してください。", successMessage: "メンバーロールを更新しました", outcomeMessages: { permission_set_granted: "メンバーのロールを更新しました。新しいロールにより権限セット ${result.grantedPermissionSet.label} も付与されました。" + }, + params: { + role: { + helpText: "所有者:組織を完全に管理でき、組織の削除もできます。\n管理者:メンバー、招待、チーム、組織の設定を管理します。\n委任管理者:ユーザーを招待し、管理者から委任された範囲にのみ配置できます。\nメンバー:権限セットで付与されたアクセス権で組織のアプリを利用します。\n外部メンバー:仕入先や顧客など組織外のユーザーで、共有されたものだけを閲覧できます。" + } } }, remove_member: { @@ -816,6 +830,7 @@ export const jaJPObjects: NonNullable = { }, params: { role: { + helpText: "所有者:組織を完全に管理でき、組織の削除もできます。\n管理者:メンバー、招待、チーム、組織の設定を管理します。\n委任管理者:ユーザーを招待し、管理者から委任された範囲にのみ配置できます。\nメンバー:権限セットで付与されたアクセス権で組織のアプリを利用します。\n外部メンバー:仕入先や顧客など組織外のユーザーで、共有されたものだけを閲覧できます。", options: { owner: "所有者", admin: "管理者", @@ -833,6 +848,7 @@ export const jaJPObjects: NonNullable = { }, resend_invitation: { label: "招待を再送", + description: "${record.email} に招待を再送しますか?有効期限は改めて始まります。", successMessage: "招待を再送しました", outcomeMessages: { sent: "${result.email} に招待を再送しました", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 91261654375..ec282359b14 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -146,6 +146,7 @@ export const zhCNObjects: NonNullable = { }, params: { role: { + helpText: "所有者:完全控制组织,包括删除组织。\n管理员:管理成员、邀请、团队和组织设置。\n受托管理员:可以邀请人员,并且只能将其安排到管理员已委托的范围内。\n成员:使用组织的应用,访问权限由其权限集决定。\n外部成员:来自组织外部的人员(如供应商或客户),只能看到与其共享的内容。", options: { owner: "所有者", admin: "管理员", @@ -585,6 +586,7 @@ export const zhCNObjects: NonNullable = { }, params: { role: { + helpText: "所有者:完全控制组织,包括删除组织。\n管理员:管理成员、邀请、团队和组织设置。\n受托管理员:可以邀请人员,并且只能将其安排到管理员已委托的范围内。\n成员:使用组织的应用,访问权限由其权限集决定。\n外部成员:来自组织外部的人员(如供应商或客户),只能看到与其共享的内容。", options: { owner: "所有者", admin: "管理员", @@ -676,6 +678,7 @@ export const zhCNObjects: NonNullable = { }, params: { role: { + helpText: "所有者:完全控制组织,包括删除组织。\n管理员:管理成员、邀请、团队和组织设置。\n受托管理员:可以邀请人员,并且只能将其安排到管理员已委托的范围内。\n成员:使用组织的应用,访问权限由其权限集决定。\n外部成员:来自组织外部的人员(如供应商或客户),只能看到与其共享的内容。", options: { owner: "所有者", admin: "管理员", @@ -688,13 +691,24 @@ export const zhCNObjects: NonNullable = { }, add_member: { label: "添加成员", - successMessage: "成员已添加" + successMessage: "成员已添加", + params: { + role: { + helpText: "所有者:完全控制组织,包括删除组织。\n管理员:管理成员、邀请、团队和组织设置。\n受托管理员:可以邀请人员,并且只能将其安排到管理员已委托的范围内。\n成员:使用组织的应用,访问权限由其权限集决定。\n外部成员:来自组织外部的人员(如供应商或客户),只能看到与其共享的内容。" + } + } }, update_member_role: { label: "更改角色", + description: "为 ${record.user_id?.name ?? '该成员'} 选择新的组织角色。", successMessage: "成员角色已更新", outcomeMessages: { permission_set_granted: "成员角色已更新。新角色还授予了权限集 ${result.grantedPermissionSet.label}。" + }, + params: { + role: { + helpText: "所有者:完全控制组织,包括删除组织。\n管理员:管理成员、邀请、团队和组织设置。\n受托管理员:可以邀请人员,并且只能将其安排到管理员已委托的范围内。\n成员:使用组织的应用,访问权限由其权限集决定。\n外部成员:来自组织外部的人员(如供应商或客户),只能看到与其共享的内容。" + } } }, remove_member: { @@ -816,6 +830,7 @@ export const zhCNObjects: NonNullable = { }, params: { role: { + helpText: "所有者:完全控制组织,包括删除组织。\n管理员:管理成员、邀请、团队和组织设置。\n受托管理员:可以邀请人员,并且只能将其安排到管理员已委托的范围内。\n成员:使用组织的应用,访问权限由其权限集决定。\n外部成员:来自组织外部的人员(如供应商或客户),只能看到与其共享的内容。", options: { owner: "所有者", admin: "管理员", @@ -833,6 +848,7 @@ export const zhCNObjects: NonNullable = { }, resend_invitation: { label: "重新发送邀请", + description: "要重新发送邀请给 ${record.email} 吗?邀请的有效期将重新计算。", successMessage: "邀请已重新发送", outcomeMessages: { sent: "已向 ${result.email} 重新发送邀请", diff --git a/packages/platform-objects/src/identity/invitation-screens-copy.test.ts b/packages/platform-objects/src/identity/invitation-screens-copy.test.ts new file mode 100644 index 00000000000..100412ae3bf --- /dev/null +++ b/packages/platform-objects/src/identity/invitation-screens-copy.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #27521 — the metadata half of the Invitations screens (rows 7, 8 and 9 of +// the 18.3.0 RC walkthrough): +// +// - row 7: `sys_invitation.status` options carry display labels, read from a +// map keyed by the spec enum, instead of the raw lowercase values; +// - row 8: every picker that grants a membership role explains each role in +// one shared help text, and Change Role names the member it changes; +// - row 9: Resend Invitation is a confirmation, not an editable form: its +// email and role are carried over from the row, and every list of +// invitations projects both; +// - the translated bundles carry a translation of every new leaf, never the +// English source `--fill=default` seeds a new key with. + +import { describe, expect, it } from 'vitest'; +import { BUILTIN_MEMBERSHIP_ROLE_OPTIONS, InvitationStatus } from '@objectstack/spec/identity'; +import { enObjects } from '../apps/translations/en.objects.generated.js'; +import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; +import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; +import { esESObjects } from '../apps/translations/es-ES.objects.generated.js'; +import { MEMBERSHIP_ROLE_PARAM_HELP } from './membership-role-help.js'; +import { SysInvitation } from './sys-invitation.object.js'; +import { SysMember } from './sys-member.object.js'; +import { SysOrganization } from './sys-organization.object.js'; +import { SysUser } from './sys-user.object.js'; + +type Action = Record; +type Param = Record; + +function action(object: unknown, name: string): Action { + const found = ((object as { actions?: Action[] }).actions ?? []).find((a) => a.name === name); + if (!found) throw new Error(`no action ${name}`); + return found; +} + +function param(declared: Action, name: string): Param { + const found = ((declared.params ?? []) as Param[]).find((p) => (p.field ?? p.name) === name); + if (!found) throw new Error(`no param ${name} on ${declared.name}`); + return found; +} + +/** Every role picker that grants a membership role, as `[object, action, declaration]`. */ +const ROLE_PICKERS: Array<[string, string, unknown]> = [ + ['sys_user', 'invite_user', SysUser], + ['sys_member', 'invite_user', SysMember], + ['sys_invitation', 'invite_user', SysInvitation], + ['sys_organization', 'invite_user', SysOrganization], + ['sys_member', 'add_member', SysMember], + ['sys_member', 'update_member_role', SysMember], +]; + +const TRANSLATED = [ + ['zh-CN', zhCNObjects], + ['ja-JP', jaJPObjects], + ['es-ES', esESObjects], +] as const; + +const bundleObject = (bundle: unknown, object: string): Record | undefined => + (bundle as Record>)?.[object]; + +describe('row 7 — invitation status options are labelled', () => { + const options = (SysInvitation as any).fields.status.options as Array<{ value: string; label: string }>; + + it('labels every value of the spec enum, in its order', () => { + expect(options.map((o) => o.value)).toEqual([...InvitationStatus.options]); + for (const option of options) { + expect(option.label, option.value).not.toBe(option.value); + expect(option.label[0], option.value).toBe(option.label[0].toUpperCase()); + } + }); + + it('reads `rejected` as "Declined", the invitee\'s own action', () => { + expect(options.find((o) => o.value === 'rejected')?.label).toBe('Declined'); + }); + + it('serves the labels in the English bundle', () => { + const en = bundleObject(enObjects, 'sys_invitation')?.fields?.status?.options; + expect(en).toEqual(Object.fromEntries(options.map((o) => [o.value, o.label]))); + }); +}); + +describe('row 8 — one role help text, a line per role', () => { + it('has one line per membership role, labelled and ordered as the options are', () => { + const lines = MEMBERSHIP_ROLE_PARAM_HELP.split('\n'); + expect(lines.map((line) => line.split(': ')[0])).toEqual(BUILTIN_MEMBERSHIP_ROLE_OPTIONS.map((o) => o.label)); + for (const line of lines) expect(line.split(': ')[1]?.length, line).toBeGreaterThan(10); + }); + + it.each(ROLE_PICKERS)('%s.%s declares it on its role param', (_object, name, declaration) => { + expect(param(action(declaration, name), 'role').helpText).toBe(MEMBERSHIP_ROLE_PARAM_HELP); + }); + + it('Change Role names the member it changes, with a fallback for a row without the user', () => { + const changeRole = action(SysMember, 'update_member_role'); + // The whole sentence, not just its hole: the console's pin + // (`ActionParamDialog.invitationScreens-27521.test.tsx`) runs this exact + // text through a real `ActionRunner`, and reads "this member" for a row + // that does not expand `user_id`. A reworded declaration reds here first. + expect(changeRole.description).toBe("Choose the new organization role for ${record.user_id?.name ?? 'this member'}."); + expect(changeRole.confirmText).toBeUndefined(); + }); +}); + +describe('row 9 — Resend Invitation is a confirmation', () => { + const resend = action(SysInvitation, 'resend_invitation'); + + it('asks its question in `description`, naming the invitee', () => { + expect(resend.description).toContain('${record.email}'); + // The spec refuses `confirmText` beside `params` on a registered action. + expect(resend.confirmText).toBeUndefined(); + }); + + it.each(['email', 'role'])('carries %s over from the row, unchanged', (name) => { + expect(param(resend, name)).toMatchObject({ required: true, defaultFromRow: true, carryOver: true }); + }); + + it.each(Object.keys((SysInvitation as any).listViews))( + 'the %s list projects both carried-over columns, so its rows can resend', + (view) => { + const columns = ((SysInvitation as any).listViews[view].columns as Array) + .map((c) => (typeof c === 'string' ? c : c.field)); + expect(columns).toEqual(expect.arrayContaining(['email', 'role'])); + }, + ); +}); + +describe('the new copy in the translated bundles', () => { + const leaves = (bundle: unknown): Array<[string, unknown]> => [ + ...ROLE_PICKERS.map(([object, name]): [string, unknown] => [ + `${object}.${name}.params.role.helpText`, + bundleObject(bundle, object)?._actions?.[name]?.params?.role?.helpText, + ]), + ['sys_invitation.resend_invitation.description', bundleObject(bundle, 'sys_invitation')?._actions?.resend_invitation?.description], + ['sys_member.update_member_role.description', bundleObject(bundle, 'sys_member')?._actions?.update_member_role?.description], + ]; + + const english = new Map(leaves(enObjects)); + + it.each(TRANSLATED)('%s translates every new leaf', (_locale, bundle) => { + for (const [path, value] of leaves(bundle)) { + expect(typeof value, path).toBe('string'); + expect(value, path).not.toBe(english.get(path)); + } + }); + + it.each(TRANSLATED)('%s keeps the holes the console fills', (_locale, bundle) => { + const byPath = new Map(leaves(bundle)); + expect(byPath.get('sys_invitation.resend_invitation.description')).toContain('${record.email}'); + expect(byPath.get('sys_member.update_member_role.description')).toMatch(/\$\{record\.user_id\?\.name \?\? '[^']+'\}/); + }); + + it.each(TRANSLATED)('%s keeps one help line per role', (_locale, bundle) => { + const help = bundleObject(bundle, 'sys_member')?._actions?.add_member?.params?.role?.helpText as string; + expect(help.split('\n')).toHaveLength(BUILTIN_MEMBERSHIP_ROLE_OPTIONS.length); + }); +}); diff --git a/packages/platform-objects/src/identity/membership-role-help.ts b/packages/platform-objects/src/identity/membership-role-help.ts new file mode 100644 index 00000000000..f7145219690 --- /dev/null +++ b/packages/platform-objects/src/identity/membership-role-help.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { BUILTIN_MEMBERSHIP_ROLE_OPTIONS, type BuiltinMembershipRole } from '@objectstack/spec/identity'; + +/** + * What each role of the closed membership vocabulary (ADR-0108) lets a member + * do. Keyed by the spec's role type, so a role the vocabulary gains fails to + * compile here until it is explained. + */ +const MEMBERSHIP_ROLE_DUTIES: Record = { + owner: 'full control of the organization, including deleting it.', + admin: 'manages members, invitations, teams and organization settings.', + delegated_admin: 'invites people and places them only where an administrator has delegated that.', + member: 'works in the organization\'s apps with the access their permission sets give.', + external: 'someone from outside the organization, such as a supplier or customer, who sees only what is shared with them.', +}; + +/** + * The help under every role picker that grants an organization membership + * role: the four `invite_user` mirrors (sys_user, sys_member, sys_invitation, + * sys_organization), `sys_member.add_member` and `sys_member.update_member_role`. + * + * Declared as the param's `helpText`, not inherited from the field: the field + * help differs per object (`sys_member.role` describes the stored column, + * `sys_invitation.role` the role an invitation confers), so a dialog reached + * from two entry points would explain the same choice two ways and name none + * of the roles. One line per role, labelled and ordered as the pickers' + * options are; the param dialog keeps the line breaks. + */ +export const MEMBERSHIP_ROLE_PARAM_HELP = BUILTIN_MEMBERSHIP_ROLE_OPTIONS + .map((option) => `${option.label}: ${MEMBERSHIP_ROLE_DUTIES[option.value as BuiltinMembershipRole]}`) + .join('\n'); diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index f7d646d1879..83fbfe2986c 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -9,6 +9,20 @@ import { MEMBERSHIP_ROLE_MEMBER, } from '@objectstack/spec/identity'; import { INVITE_USER_OUTCOME_MESSAGES, RESEND_INVITATION_OUTCOME_MESSAGES } from './invite-outcome-messages.js'; +import { MEMBERSHIP_ROLE_PARAM_HELP } from './membership-role-help.js'; + +/** + * The display label of each invitation status. Keyed by the spec enum, so a + * value the enum gains fails to compile here until it has a label. `rejected` + * reads "Declined": the invitee's action is Decline Invitation. + */ +const INVITATION_STATUS_LABELS: Record = { + pending: 'Pending', + accepted: 'Accepted', + rejected: 'Declined', + expired: 'Expired', + canceled: 'Canceled', +}; /** * sys_invitation — System Invitation Object @@ -90,7 +104,7 @@ export const SysInvitation = ObjectSchema.create({ // (`INVITATION_ROLE_GRANTORS`), so a delegated_admin is offered // `member` alone. Declared on the param, not inherited from the // field, so the gate narrows this dialog and no other picker. - { field: 'role', required: true, options: [...INVITATION_ROLE_OPTIONS] }, + { field: 'role', required: true, options: [...INVITATION_ROLE_OPTIONS], helpText: MEMBERSHIP_ROLE_PARAM_HELP }, ], }, { @@ -112,6 +126,12 @@ export const SysInvitation = ObjectSchema.create({ { name: 'resend_invitation', label: 'Resend Invitation', + // A confirmation, not a form: the question rides `description` (one + // dialog, see `action-confirm-one-dialog.test.ts`) and both params are + // carried over from the row. invite-member renews a pending invitation + // with its stored role whatever role is sent, and another email would + // invite a different person, so neither is the user's to edit here. + description: 'Resend the invitation to ${record.email}? Its expiry date starts over.', icon: 'send', variant: 'secondary', locations: ['list_item'], @@ -126,9 +146,11 @@ export const SysInvitation = ObjectSchema.create({ // [#27843] A resend answers the same delivery `outcome` as an invite. outcomeMessages: { ...RESEND_INVITATION_OUTCOME_MESSAGES }, refreshAfter: true, + // Both seeded from the row, so every list view that offers this action + // projects `email` and `role` (`invitation-screens-copy.test.ts`). params: [ - { field: 'email', required: true, defaultFromRow: true }, - { field: 'role', required: true, defaultFromRow: true }, + { field: 'email', required: true, defaultFromRow: true, carryOver: true }, + { field: 'role', required: true, defaultFromRow: true, carryOver: true }, ], }, { @@ -247,7 +269,7 @@ export const SysInvitation = ObjectSchema.create({ name: 'expired', label: 'Expired / Canceled', data: { provider: 'object', object: 'sys_invitation' }, - columns: ['email', 'status', 'organization_id', 'expires_at'], + columns: ['email', 'status', 'role', 'organization_id', 'expires_at'], filter: [{ field: 'status', operator: 'in', value: ['expired', 'rejected', 'canceled'] }], sort: [{ field: 'expires_at', order: 'desc' }], pagination: { pageSize: 50 }, @@ -328,10 +350,14 @@ export const SysInvitation = ObjectSchema.create({ // the enum stopped at four values and so rejected a canceled row). Reading // the options from the enum makes a repeat impossible rather than merely // discouraged; `sys-invitation.status-vocabulary.test.ts` pins the rest. - status: Field.select([...InvitationStatus.options], { + // Each value carries a display label (`INVITATION_STATUS_LABELS`): the + // bare-string shorthand labelled every option with its stored value, so + // the list drew `pending` / `canceled` beside title-case tabs. + status: Field.select({ label: 'Status', required: true, defaultValue: 'pending', + options: InvitationStatus.options.map((value) => ({ value, label: INVITATION_STATUS_LABELS[value] })), }), inviter_id: Field.lookup('sys_user', { diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index e4500454da6..fa5730dddd9 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -8,6 +8,7 @@ import { MEMBERSHIP_ROLE_MEMBER, } from '@objectstack/spec/identity'; import { INVITE_USER_OUTCOME_MESSAGES } from './invite-outcome-messages.js'; +import { MEMBERSHIP_ROLE_PARAM_HELP } from './membership-role-help.js'; /** * sys_member — System Member Object @@ -127,7 +128,7 @@ export const SysMember = ObjectSchema.create({ // (`INVITATION_ROLE_GRANTORS`), so a delegated_admin is offered // `member` alone. Declared on the param, not inherited from the // field, so the gate narrows this dialog and no other picker. - { field: 'role', required: true, options: [...INVITATION_ROLE_OPTIONS] }, + { field: 'role', required: true, options: [...INVITATION_ROLE_OPTIONS], helpText: MEMBERSHIP_ROLE_PARAM_HELP }, ], }, { @@ -196,13 +197,17 @@ export const SysMember = ObjectSchema.create({ refreshAfter: true, params: [ { name: 'userId', field: 'user_id', required: true }, - { field: 'role', required: true }, + { field: 'role', required: true, helpText: MEMBERSHIP_ROLE_PARAM_HELP }, { name: 'organizationId', field: 'organization_id' }, ], }, { name: 'update_member_role', label: 'Change Role', + // Names the member: a list row carries `user_id` expanded to the user + // record wherever the list shows that column (the organization page's + // Members list); a row without it reads "this member". + description: "Choose the new organization role for ${record.user_id?.name ?? 'this member'}.", icon: 'shield', mode: 'edit', locations: ['list_item'], @@ -222,7 +227,7 @@ export const SysMember = ObjectSchema.create({ }, refreshAfter: true, params: [ - { field: 'role', required: true, defaultFromRow: true }, + { field: 'role', required: true, defaultFromRow: true, helpText: MEMBERSHIP_ROLE_PARAM_HELP }, ], }, { diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 87832048e47..e97a16bb9a3 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -3,6 +3,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { INVITATION_ROLE_OPTIONS } from '@objectstack/spec/identity'; import { INVITE_USER_OUTCOME_MESSAGES } from './invite-outcome-messages.js'; +import { MEMBERSHIP_ROLE_PARAM_HELP } from './membership-role-help.js'; /** * sys_organization — System Organization Object @@ -194,7 +195,7 @@ export const SysOrganization = ObjectSchema.create({ // is sys_invitation's: both required, and the role picker offers only // what this caller may grant ([ADR-0105 D8] `INVITATION_ROLE_OPTIONS`). { field: 'email', objectOverride: 'sys_invitation', required: true }, - { field: 'role', objectOverride: 'sys_invitation', required: true, options: [...INVITATION_ROLE_OPTIONS] }, + { field: 'role', objectOverride: 'sys_invitation', required: true, options: [...INVITATION_ROLE_OPTIONS], helpText: MEMBERSHIP_ROLE_PARAM_HELP }, ], }, { diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 5b87039d20d..2f2f8b00a46 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -3,6 +3,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { INVITATION_ROLE_OPTIONS } from '@objectstack/spec/identity'; import { INVITE_USER_OUTCOME_MESSAGES } from './invite-outcome-messages.js'; +import { MEMBERSHIP_ROLE_PARAM_HELP } from './membership-role-help.js'; /** * The BCP-47 shape `sys_user.locale` accepts — a 2–3 letter language subtag @@ -138,7 +139,7 @@ export const SysUser = ObjectSchema.create({ // (`INVITATION_ROLE_GRANTORS`), so a delegated_admin is offered // `member` alone. Declared on the param, not inherited from the // field, so the gate narrows this dialog and no other picker. - { field: 'role', objectOverride: 'sys_member', required: true, options: [...INVITATION_ROLE_OPTIONS] }, + { field: 'role', objectOverride: 'sys_member', required: true, options: [...INVITATION_ROLE_OPTIONS], helpText: MEMBERSHIP_ROLE_PARAM_HELP }, ], }, diff --git a/ui/apps/console/src/pages/auth/LoginPage.tsx b/ui/apps/console/src/pages/auth/LoginPage.tsx index 3847f5e1074..69346af0a08 100644 --- a/ui/apps/console/src/pages/auth/LoginPage.tsx +++ b/ui/apps/console/src/pages/auth/LoginPage.tsx @@ -65,6 +65,7 @@ import { needsBootstrapProbe, useBootstrapStatus, InvitationGoneNotice, + InvitationLinkHint, InvitationSignUpPrompt, invitationArrivalCopy, useInvitationArrival, @@ -366,6 +367,7 @@ export function LoginPage() { registerUrl={offerSignUp && !invitationRedirect ? registerUrl : undefined} redirect={redirect} /> + {devSeedAdmin ? : null} )} diff --git a/ui/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx b/ui/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx index 728d2269671..a818d3b8216 100644 --- a/ui/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx +++ b/ui/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx @@ -237,7 +237,7 @@ describe('objectui#3811 — console routes DefaultAcceptInvitationPage', () => { ).toBeInTheDocument(); }); - it('accept switches the user into the invited organization, then reloads onto the console ROOT', async () => { + it('accept switches the user into the invited organization, says they joined, and Continue reloads onto the console ROOT', async () => { // objectui#7373 moved this landing. It used to be an in-router // `navigate('/home')` — the environment launcher, reached without leaving // the SPA, which is exactly why it could not honour the declaration: the @@ -260,6 +260,18 @@ describe('objectui#3811 — console routes DefaultAcceptInvitationPage', () => { (authState.switchOrganization as ReturnType).mock.invocationCallOrder[0], ); + // The page says the user joined, and waits for Continue (#27521 row 11): + // the confirmation is on the page, because the page load below would drop + // a toast before anyone read it. + const joined = await screen.findByTestId('accept-invitation-joined'); + expect(joined).toHaveTextContent('Invitation accepted'); + expect(joined).toHaveTextContent('Welcome to Acme Corp. You joined as Admin.'); + expect(navigations).toHaveLength(0); + expect(toastSuccess).not.toHaveBeenCalled(); + const proceed = screen.getByRole('button', { name: 'Continue' }); + expect(proceed).toHaveFocus(); + await user.click(proceed); + // A full page load, not a router transition — the whole point is that // every data scope is re-seated on the new organization before anything // reads the app list. @@ -281,8 +293,6 @@ describe('objectui#3811 — console routes DefaultAcceptInvitationPage', () => { // load were also performed. expect(screen.queryByTestId('home-sentinel')).not.toBeInTheDocument(); expect(seen).not.toContain('/home'); - - expect(toastSuccess).toHaveBeenCalledWith('Invitation accepted'); }); it('decline rejects the invitation and lands on /organizations', async () => { diff --git a/ui/apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx b/ui/apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx index 58f670721b9..3b979546584 100644 --- a/ui/apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx +++ b/ui/apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx @@ -36,7 +36,7 @@ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { act, render, screen, cleanup, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; import { I18nProvider } from '@object-ui/i18n'; @@ -221,6 +221,33 @@ describe('LoginPage — the "Sign up" link follows the audience posture (objectu }); }); +describe('LoginPage — an invitee without the link is told to open it (objectstack#27521 row 11)', () => { + const HINT = builtInLocales.en.auth.login.invitationLinkHint; + + it('under invite_only on a deployment with an owner, says so in the card', async () => { + renderAt('/login', configFor('invite_only')); + await loginConfigApplied(); + expect(await screen.findByTestId('invitation-link-hint')).toHaveTextContent(HINT); + expect(screen.getByTestId('login-card')).toContainElement(screen.getByTestId('invitation-link-hint')); + }); + + it('is not drawn where a sign-up is offered or for an invitation redirect', async () => { + for (const [path, config, hasOwner] of [ + ['/login', configFor('open'), true], + ['/login', configFor('invite_only'), false], + [`/login${INVITE_QUERY}`, configFor('invite_only'), true], + ] as const) { + renderAt(path, config, { hasOwner }); + await loginConfigApplied(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(screen.queryByTestId('invitation-link-hint'), `${path} owner=${hasOwner}`).toBeNull(); + cleanup(); + } + }); +}); + describe('RegisterPage — explains invitation-only registration before the form (objectui#11691)', () => { it('under invite_only without an invitation, explains instead of rendering the form', async () => { renderAt('/register', configFor('invite_only')); diff --git a/ui/packages/app-shell/src/console/auth/LoginPage.tsx b/ui/packages/app-shell/src/console/auth/LoginPage.tsx index 925eecbe0a7..a0d26ac9b76 100644 --- a/ui/packages/app-shell/src/console/auth/LoginPage.tsx +++ b/ui/packages/app-shell/src/console/auth/LoginPage.tsx @@ -23,7 +23,8 @@ * inviter when the signed-out invitation preview answers (#26473, * `useInvitationArrival`), and keeps the generic copy when it does not. For an * invitation the server no longer knows it draws `InvitationGoneNotice` alone: - * no prompt and no form (#27509). + * no prompt and no form (#27509). A visitor without the link, where sign-up is + * by invitation only, is told under the form to open it (`InvitationLinkHint`). */ import { useEffect, useState } from 'react'; @@ -45,6 +46,7 @@ import { decideSignUpOffer, isInvitationRedirect, needsBootstrapProbe } from './ import { useBootstrapStatus } from './bootstrapStatus.js'; import { InvitationGoneNotice, + InvitationLinkHint, InvitationSignUpPrompt, invitationArrivalCopy, useInvitationArrival, @@ -205,7 +207,17 @@ export function LoginPage() { /> ); - if (!invitationRedirect) return {form}; + if (!invitationRedirect) { + return ( + + {/* A column, so the hint sits under the form in the layout's row. */} +
+ {form} + +
+
+ ); + } if (invitationArrival.gone) { return ( diff --git a/ui/packages/app-shell/src/console/auth/__tests__/defaultPagesFollowPosture-11705.test.tsx b/ui/packages/app-shell/src/console/auth/__tests__/defaultPagesFollowPosture-11705.test.tsx index ae3419bb1e8..a3aec01248e 100644 --- a/ui/packages/app-shell/src/console/auth/__tests__/defaultPagesFollowPosture-11705.test.tsx +++ b/ui/packages/app-shell/src/console/auth/__tests__/defaultPagesFollowPosture-11705.test.tsx @@ -221,6 +221,32 @@ describe('DefaultLoginPage — the "Sign up" link follows the audience posture ( }); }); +describe('DefaultLoginPage — an invitee without the link is told to open it (objectstack#27521 row 11)', () => { + const HINT = en.login.invitationLinkHint; + + it('under invite_only on a deployment with an owner, says so under the form', async () => { + renderAt('/login', configFor('invite_only')); + await loginConfigApplied(); + expect(await screen.findByText(HINT)).toBeInTheDocument(); + expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); + }); + + it('is not drawn where a sign-up is offered, for an invitation redirect, or with sign-up off', async () => { + for (const [path, config, hasOwner] of [ + ['/login', configFor('open'), true], + ['/login', configFor('invite_only'), false], + [`/login${INVITE_QUERY}`, configFor('invite_only'), true], + ['/login', configFor('invite_only', SIGN_UP_OFF), true], + ] as const) { + renderAt(path, config, { hasOwner }); + await loginConfigApplied(); + await settle(); + expect(screen.queryByText(HINT), `${path} ${JSON.stringify(config.features)} owner=${hasOwner}`).toBeNull(); + cleanup(); + } + }); +}); + describe('DefaultRegisterPage — explains invitation-only registration before the form (objectui#11705)', () => { it('under invite_only without an invitation, explains instead of rendering the form', async () => { renderAt('/register', configFor('invite_only')); diff --git a/ui/packages/app-shell/src/console/auth/invitationArrival.tsx b/ui/packages/app-shell/src/console/auth/invitationArrival.tsx index 9cfcab92050..65ee517be3b 100644 --- a/ui/packages/app-shell/src/console/auth/invitationArrival.tsx +++ b/ui/packages/app-shell/src/console/auth/invitationArrival.tsx @@ -48,6 +48,7 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { AuthFormHeader, AUTH_LINK_CLASS, useAuth, type AuthInvitationPreview } from '@object-ui/auth'; import { useObjectTranslation, type TranslateFn } from '@object-ui/i18n'; +import type { SignUpOffer } from './signUpOffer.js'; /** A full-width outlined link with the auth primary button's box. */ const INVITATION_SIGN_UP_CLASS = @@ -78,6 +79,26 @@ export function InvitationSignUpPrompt({ registerUrl }: { registerUrl: string }) ); } +/** + * The plain sign-in page's line for an invitee who came without the link. + * Under `by-invitation` the page offers no sign-up, so a new invitee reading it + * had no way on; the way on is the link in the invitation email, which lands + * on `?redirect=/accept-invitation/ID` and its sign-up prompt. Drawn for that + * offer only: `decideSignUpOffer` never answers it for an invitation redirect, + * and under `closed` the link would not offer a sign-up either. + */ +export function InvitationLinkHint({ offer }: { offer: SignUpOffer }) { + const { t } = useObjectTranslation(); + if (offer !== 'by-invitation') return null; + return ( +

+ {t('auth.login.invitationLinkHint', { + defaultValue: 'Have an invitation? Open the link in the invitation email to accept it.', + })} +

+ ); +} + /** The basename-stripped route an invitation link opens, as `./signUpOffer` reads it. */ const INVITATION_ROUTE_PREFIX = '/accept-invitation/'; diff --git a/ui/packages/app-shell/src/console/organizations/__tests__/org-i18n-holdouts-4474.test.tsx b/ui/packages/app-shell/src/console/organizations/__tests__/org-i18n-holdouts-4474.test.tsx index 96dcd6a508e..27b2104daf5 100644 --- a/ui/packages/app-shell/src/console/organizations/__tests__/org-i18n-holdouts-4474.test.tsx +++ b/ui/packages/app-shell/src/console/organizations/__tests__/org-i18n-holdouts-4474.test.tsx @@ -432,6 +432,14 @@ describe('#4474 family 3 — icon-only buttons carry a translated aria-label — await waitFor(() => expect(screen.getByLabelText('复制邀请链接')).toBeInTheDocument()); }); + it('the cancel dialog dismisses with "Keep invitation", not a second "Cancel" (objectstack#27521 row 9)', async () => { + renderIn('en', ); + await waitFor(() => expect(screen.getByTestId('invitations-page')).toBeInTheDocument()); + fireEvent.click(screen.getByLabelText('Cancel invitation')); + expect(await screen.findByRole('button', { name: 'Keep invitation' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull(); + }); + it('MUST NOT CHANGE — en aria-labels stay the English the card measured', async () => { renderIn('en', ); await waitFor(() => expect(screen.getByTestId('invitations-page')).toBeInTheDocument()); diff --git a/ui/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx b/ui/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx index 28919dd0d12..6882c542a22 100644 --- a/ui/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx +++ b/ui/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx @@ -48,6 +48,9 @@ export function AcceptInvitationPage() { const [error, setError] = useState(null); const [isAccepting, setIsAccepting] = useState(false); const [isDeclining, setIsDeclining] = useState(false); + // Set once the membership exists and the session is switched to it: the page + // then says the user joined, and Continue enters the organization. + const [joined, setJoined] = useState(false); // Redirect to login if not authenticated. // @@ -98,38 +101,7 @@ export function AcceptInvitationPage() { try { await acceptInvitation(invitationId); await switchOrganization(invitation.organizationId).catch(() => null); - toast.success(t('organization.accept.accepted', { defaultValue: 'Invitation accepted' })); - // The console ROOT, as a full-page navigation — NOT a router `navigate()` - // and NOT `useHomePath()` (objectui#7373). - // - // ⭐ WHY THE HOOK IS WRONG *HERE*, while it is right at this card's nine - // other sites: the app list this component can read belongs to the - // organization the user is LEAVING. `switchOrganization` one line up only - // updates auth state; `MetadataProvider` drops its whole cache when the - // active org changes (objectui#4486) and refetches, and this line runs - // before any of that can land. A declared-landing answer read here would - // therefore name the PREVIOUS org's app — an app the new organization may - // not even carry — which is worse than the launcher this replaced. - // - // Landing on the ROOT resolves the declaration AFTER the switch instead - // of before it: the full page load re-seats every data scope on the new - // org, then `RootLandingRedirect` reads the new org's list and applies - // `resolveLandingPath` — the declared landing, a single-app workspace's - // one app, or the launcher. So this page honours `app.isDefault` for the - // organization the user just joined, which is what objectui#7373 asked - // for, by the only route that can read it. - // - // This is the shape both other org-switch paths already take, for this - // same reason, and their comments say so: `layout/WorkspaceSwitcher.tsx` - // (`handleSwitch`) and `console/organizations/OrganizationsPage.tsx` - // (`handleSelect`). Accepting an invitation is the third site of one - // transition; it was the only one not taking it. - // - // `resolveRootUrl()` and not a bare `'/'`: a full-page navigation leaves - // React Router, so nothing applies the host's `basename`, and a console - // served under `` would drop the user at the - // origin root, outside the SPA. - window.location.href = resolveRootUrl(); + setJoined(true); } catch (err) { // objectui#4474 — the card's site 5: a wrong recipient produced better-auth's // English sentence under the translated title. Mapped by `code` now. @@ -143,6 +115,43 @@ export function AcceptInvitationPage() { } }; + // Continue from the "you joined" state. The confirmation is the page, not a + // toast: the full-page navigation below would drop a toast before anyone + // read it. + const handleContinue = () => { + // The console ROOT, as a full-page navigation — NOT a router `navigate()` + // and NOT `useHomePath()` (objectui#7373). + // + // ⭐ WHY THE HOOK IS WRONG *HERE*, while it is right at this card's nine + // other sites: the app list this component can read belongs to the + // organization the user is LEAVING. `switchOrganization` in `handleAccept` + // only updates auth state; `MetadataProvider` drops its whole cache when the + // active org changes (objectui#4486) and refetches, and nothing here waits + // for that to land. A declared-landing answer read here would + // therefore name the PREVIOUS org's app — an app the new organization may + // not even carry — which is worse than the launcher this replaced. + // + // Landing on the ROOT resolves the declaration AFTER the switch instead + // of before it: the full page load re-seats every data scope on the new + // org, then `RootLandingRedirect` reads the new org's list and applies + // `resolveLandingPath` — the declared landing, a single-app workspace's + // one app, or the launcher. So this page honours `app.isDefault` for the + // organization the user just joined, which is what objectui#7373 asked + // for, by the only route that can read it. + // + // This is the shape both other org-switch paths already take, for this + // same reason, and their comments say so: `layout/WorkspaceSwitcher.tsx` + // (`handleSwitch`) and `console/organizations/OrganizationsPage.tsx` + // (`handleSelect`). Accepting an invitation is the third site of one + // transition; it was the only one not taking it. + // + // `resolveRootUrl()` and not a bare `'/'`: a full-page navigation leaves + // React Router, so nothing applies the host's `basename`, and a console + // served under `` would drop the user at the + // origin root, outside the SPA. + window.location.href = resolveRootUrl(); + }; + const handleDecline = async () => { if (!invitationId) return; setIsDeclining(true); @@ -194,6 +203,28 @@ export function AcceptInvitationPage() { {t('organization.accept.goToOrgs', { defaultValue: 'Go to organizations' })} + ) : invitation && joined ? ( +
+
+ +
+
+

+ {t('organization.accept.accepted', { defaultValue: 'Invitation accepted' })} +

+

+ {t('organization.accept.joinedDescription', { + defaultValue: 'Welcome to {{orgName}}. You joined as {{role}}.', + orgName: invitation.organizationName ?? invitation.organizationId, + role: resolveOrgRoleLabel(invitation.role, t), + })} +

+
+ {/* Focused when the state appears, so Enter continues. */} + +
) : invitation ? (
diff --git a/ui/packages/app-shell/src/console/organizations/manage/InvitationsPage.tsx b/ui/packages/app-shell/src/console/organizations/manage/InvitationsPage.tsx index 6ab88540275..ce589d34265 100644 --- a/ui/packages/app-shell/src/console/organizations/manage/InvitationsPage.tsx +++ b/ui/packages/app-shell/src/console/organizations/manage/InvitationsPage.tsx @@ -307,7 +307,9 @@ export function InvitationsPage() { - {t('common.cancel', { defaultValue: 'Cancel' })} + {/* Says what dismissing does: beside "Cancel invitation", a bare + "Cancel" reads as the same verb. */} + {t('organization.invitations.keepAction', { defaultValue: 'Keep invitation' })} { state.resolve?.(true); onOpenChange(false); @@ -56,7 +59,7 @@ export function ActionConfirmDialog({ state, onOpenChange }: ActionConfirmDialog { if (!open) handleCancel(); }}> - + {state.options?.title || t('actionConfirm.title')} {state.message} diff --git a/ui/packages/app-shell/src/views/ActionParamDialog.invitationScreens-27521.test.tsx b/ui/packages/app-shell/src/views/ActionParamDialog.invitationScreens-27521.test.tsx new file mode 100644 index 00000000000..827d6c82b94 --- /dev/null +++ b/ui/packages/app-shell/src/views/ActionParamDialog.invitationScreens-27521.test.tsx @@ -0,0 +1,156 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27521 — the param dialog half of the Invitations screens. + * + * - Row 10: an email or url param is checked against the format rule the + * object form checks that field type with (`buildValidationRules`), and a + * malformed value is marked on its field before anything runs. The server + * used to be the only check, and its refusal showed twice (banner and toast) + * with the field unmarked. + * - Row 8: a help text keeps its line breaks, so the role help reads one role + * per line; Change Role's description names the member from a list row. + * + * Rendered under a real English `I18nProvider`, so the messages asserted are + * the ones a user reads. + */ +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { ActionRunner, type ActionDef, type ActionParamDef } from '@object-ui/core'; +// Module scope, per AGENTS.md 测试纪律: the dialog's widgets sit behind +// `React.lazy`, and this barrel statically re-exports them. +import '@object-ui/fields'; +import { ActionParamDialog, paramFormatErrorKey, type ParamDialogState } from './ActionParamDialog'; + +afterEach(() => cleanup()); + +function openDialog(params: ActionParamDef[], extra: Partial = {}) { + const resolve = vi.fn(); + render( + + {}} /> + , + ); + return resolve; +} + +const EMAIL: ActionParamDef = { name: 'email', label: 'Email', type: 'email', required: true }; +const confirm = () => fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + +describe('row 10 — a malformed email is marked on its field (objectstack#27521)', () => { + it('blocks Confirm and shows the message under the field, which reads invalid', async () => { + const run = vi.fn(); + const resolve = openDialog([EMAIL], { run }); + const input = await screen.findByLabelText(/Email/); + fireEvent.change(input, { target: { value: 'not-an-email' } }); + confirm(); + expect(await screen.findByText('Please enter a valid email address')).toBeInTheDocument(); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(run).not.toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + // One place says it: the field. The run's refusal banner is never drawn. + expect(screen.queryByTestId('action-param-refusal')).toBeNull(); + }); + + it('clears the mark on the next keystroke and confirms a well-formed address', async () => { + const resolve = openDialog([EMAIL]); + const input = await screen.findByLabelText(/Email/); + fireEvent.change(input, { target: { value: 'ada@' } }); + confirm(); + await screen.findByText('Please enter a valid email address'); + fireEvent.change(input, { target: { value: 'ada@example.com' } }); + expect(screen.queryByText('Please enter a valid email address')).toBeNull(); + expect(input).toHaveAttribute('aria-invalid', 'false'); + confirm(); + await waitFor(() => expect(resolve).toHaveBeenCalledWith({ email: 'ada@example.com' })); + }); + + it('an empty required email still reads as required, not as malformed', async () => { + openDialog([EMAIL]); + await screen.findByLabelText(/Email/); + confirm(); + expect(await screen.findByText('Email is required')).toBeInTheDocument(); + expect(screen.queryByText('Please enter a valid email address')).toBeNull(); + }); + + it('a url param is held to the url rule', async () => { + const resolve = openDialog([{ name: 'site', label: 'Website', type: 'url' }]); + const input = await screen.findByLabelText(/Website/); + fireEvent.change(input, { target: { value: 'example.com' } }); + confirm(); + expect(await screen.findByText('Please enter a valid URL')).toBeInTheDocument(); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('CONTROL: a text param accepts the same string', async () => { + const resolve = openDialog([{ name: 'note', label: 'Note', type: 'text', required: true }]); + const input = await screen.findByLabelText(/Note/); + fireEvent.change(input, { target: { value: 'not-an-email' } }); + confirm(); + await waitFor(() => expect(resolve).toHaveBeenCalledWith({ note: 'not-an-email' })); + }); + + it('judges neither an empty optional value nor a carried-over one', () => { + const email: ActionParamDef = { name: 'email', label: 'Email', type: 'email' }; + expect(paramFormatErrorKey(email, '')).toBeNull(); + expect(paramFormatErrorKey({ ...email, carryOver: true }, 'not-an-email')).toBeNull(); + expect(paramFormatErrorKey(email, 'not-an-email')).toBe('validation.email'); + }); +}); + +describe('row 8 — the role help and Change Role (objectstack#27521)', () => { + const HELP = 'Owner: full control.\nMember: works in the apps.'; + + it('keeps the help text\'s line breaks under the control', async () => { + openDialog([{ name: 'role', label: 'Role', type: 'text', helpText: HELP }]); + const help = await screen.findByText((_text, node) => node?.tagName === 'P' && node.textContent === HELP); + expect(help).toHaveClass('whitespace-pre-line'); + }); + + it('keeps them under a carried-over value too', async () => { + openDialog([{ name: 'role', label: 'Role', type: 'text', helpText: HELP, carryOver: true, defaultValue: 'member' }]); + const help = await screen.findByText((_text, node) => node?.tagName === 'P' && node.textContent === HELP); + expect(help).toHaveClass('whitespace-pre-line'); + }); + + /** + * `sys_member.update_member_role`'s description, as declared in + * `@objectstack/platform-objects` (not a dependency of this tree; its own + * pin, `invitation-screens-copy.test.ts`, holds the declaration to it). + */ + const CHANGE_ROLE: ActionDef = { + name: 'update_member_role', + label: 'Change Role', + description: "Choose the new organization role for ${record.user_id?.name ?? 'this member'}.", + actionParams: [{ name: 'role', label: 'Role', type: 'select', required: true }], + }; + + async function descriptionFor(row: Record): Promise { + const runner = new ActionRunner({ objectName: 'sys_member' }); + let read = ''; + runner.setParamCollectionHandler(async (_params, action, interpolate) => { + read = interpolate!(String(action?.description)); + return null; + }); + await runner.execute({ ...CHANGE_ROLE, params: { _rowRecord: row } }); + return read; + } + + it('names the member from a list row that expands the user', async () => { + const row = { id: 'mem_1', role: 'member', user_id: { id: 'usr_1', name: 'Ada Lovelace' } }; + expect(await descriptionFor(row)).toBe('Choose the new organization role for Ada Lovelace.'); + }); + + it('reads "this member" for a row that does not carry the user', async () => { + expect(await descriptionFor({ id: 'mem_1', role: 'member' })).toBe('Choose the new organization role for this member.'); + }); +}); diff --git a/ui/packages/app-shell/src/views/ActionParamDialog.tsx b/ui/packages/app-shell/src/views/ActionParamDialog.tsx index 0216174d3a2..bb5693535b7 100644 --- a/ui/packages/app-shell/src/views/ActionParamDialog.tsx +++ b/ui/packages/app-shell/src/views/ActionParamDialog.tsx @@ -83,8 +83,10 @@ import { EXPANDABLE_FIELD_TYPES, } from '@object-ui/core'; import { usePredicateScope } from '@object-ui/react'; -import { getLazyFieldWidget, fileIdOf } from '@object-ui/fields'; +import { getLazyFieldWidget, fileIdOf, buildValidationRules, resolveParamWidgetType } from '@object-ui/fields'; +import { useInitialFieldFocus } from '@object-ui/plugin-form'; import { paramToField, paramDegradesWithoutTarget } from '../utils/paramToField.js'; +import { useActionDialogReturnFocus } from './actionDialogReturnFocus.js'; export interface ParamDialogState { open: boolean; @@ -299,6 +301,20 @@ export function serializeParamValues( } /** Skeleton shown while a lazy field widget's chunk loads. */ +/** + * The i18n key of the format rule a typed value breaks, or `null`. The rule is + * the one the object form checks the same field type with + * (`buildValidationRules`: email and url), so a param and a form field of one + * type accept the same values. An empty value is the required check's; a + * carried-over value is not the user's to fix. + */ +export function paramFormatErrorKey(param: ActionParamDef, value: unknown): string | null { + if (param.carryOver || typeof value !== 'string' || value === '') return null; + const pattern = buildValidationRules({ type: resolveParamWidgetType(param.type) })?.pattern; + if (!pattern || !(pattern.value instanceof RegExp)) return null; + return pattern.value.test(value) ? null : (pattern.messageKey ?? 'validation.pattern'); +} + function WidgetFallback() { return ); } @@ -424,7 +440,9 @@ function paramNeedsDependentValues(widgetType: string): boolean { export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProps) { const { t, language } = useObjectTranslation(); const [values, setValues] = useState>({}); - const [errors, setErrors] = useState>({}); + // Each failing param's message, as an i18n key (`actionDialog.requiredError` + // or a format rule's key), rendered with the param's label. + const [errors, setErrors] = useState>({}); // Params whose upload widget (file/image/avatar) is mid-upload. Confirm stays // disabled while any is in flight so a param can't be submitted before its // fileId resolves (the value is only the fileId once the upload settles). @@ -532,12 +550,16 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp // legitimately collect, so the action is not launchable from here. Confirm // is disabled too; this guards keyboard submit (`6cc910b6d`). if (unresolvedKey) return; - // Validate required fields - const newErrors: Record = {}; + // Validate required fields, then the format of what was typed, so a + // malformed value is marked on its field instead of refused by the server. + const newErrors: Record = {}; for (const param of visibleParams) { if (param.required && isMissingValue(values[param.name])) { - newErrors[param.name] = true; + newErrors[param.name] = 'actionDialog.requiredError'; + continue; } + const formatKey = paramFormatErrorKey(param, values[param.name]); + if (formatKey) newErrors[param.name] = formatKey; } if (Object.keys(newErrors).length > 0) { setErrors(newErrors); @@ -579,12 +601,21 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp const updateValue = (name: string, value: any) => { setValues(prev => ({ ...prev, [name]: value })); - setErrors(prev => ({ ...prev, [name]: false })); + setErrors(prev => ({ ...prev, [name]: '' })); }; // A long list widens to two columns (objectui#12080 — see the header). const isLongList = visibleParams.length > NARROW_PARAM_MAX_COUNT; + // Focus opens on the first editable param (its widget is lazy, so it lands + // after the content mounts) and returns to the control that opened the + // dialog. Editable rows carry `data-field`; carry-over and unresolved rows + // do not, so a dialog of carried-over values opens on itself. + const initialFocus = useInitialFieldFocus(); + const returnFocus = useActionDialogReturnFocus(state.open); + const errorText = (name: string, label: string): string | undefined => + errors[name] ? t(errors[name], { label, field: label }) : undefined; + return ( { if (!open) handleCancel(); @@ -592,7 +623,15 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp {/* Bounded (objectui#12080): a flex column at most 90vh tall, in which only the body below can give up height (`min-h-0`) and scroll, so the header and the footer always stay on screen. */} - + { + initialFocus.onCloseAutoFocus(event); + returnFocus.onCloseAutoFocus(event); + }} + > {state.title || t('actionDialog.title')} {/* `whitespace-pre-line` so a description composed of more than one @@ -656,7 +695,7 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp helpText={param.helpText} value={values[param.name]} hint={t('actionDialog.carryOverHint')} - error={errors[param.name] ? t('actionDialog.requiredError', { label: param.label }) : undefined} + error={errorText(param.name, param.label)} /> ); } @@ -756,7 +795,7 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp // instead of above it; help text appears underneath). if (field.type === 'boolean') { return ( -
+
{errors[param.name] && ( -

{t('actionDialog.requiredError', { label: param.label })}

+

{errorText(param.name, param.label)}

)} {param.helpText && ( -

{param.helpText}

+

{param.helpText}

)}
); } return ( -
+