From af99f3cedb8373e1538ce550e9f45ba3c666d342 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:38:20 +0000 Subject: [PATCH 1/4] =?UTF-8?q?wip:=20invitation=20screens=20=E2=80=94=20s?= =?UTF-8?q?tatus=20labels,=20role=20help,=20resend=20confirm,=20param=20di?= =?UTF-8?q?alog=20checks=20and=20focus,=20joined=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibyekmZuiE5TVrwDWqH --- .../apps/translations/en.objects.generated.ts | 28 +++- .../translations/es-ES.objects.generated.ts | 18 ++- .../translations/ja-JP.objects.generated.ts | 18 ++- .../translations/zh-CN.objects.generated.ts | 18 ++- .../src/identity/membership-role-help.ts | 21 +++ .../src/identity/sys-invitation.object.ts | 36 ++++- .../src/identity/sys-member.object.ts | 11 +- .../src/identity/sys-organization.object.ts | 3 +- .../src/identity/sys-user.object.ts | 3 +- ui/apps/console/src/pages/auth/LoginPage.tsx | 2 + .../__tests__/AcceptInvitationRoute.test.tsx | 16 +- .../app-shell/src/console/auth/LoginPage.tsx | 16 +- .../src/console/auth/invitationArrival.tsx | 21 +++ .../manage/AcceptInvitationPage.tsx | 95 +++++++---- .../organizations/manage/InvitationsPage.tsx | 4 +- ui/packages/app-shell/src/index.ts | 3 +- .../src/views/ActionConfirmDialog.tsx | 5 +- .../app-shell/src/views/ActionParamDialog.tsx | 71 +++++++-- .../src/views/ActionResultDialog.tsx | 5 + .../src/views/actionDialogReturnFocus.ts | 150 ++++++++++++++++++ ui/packages/i18n/src/locales/ar.ts | 4 + ui/packages/i18n/src/locales/de.ts | 4 + ui/packages/i18n/src/locales/en.ts | 4 + ui/packages/i18n/src/locales/es.ts | 4 + ui/packages/i18n/src/locales/fr.ts | 4 + ui/packages/i18n/src/locales/ja.ts | 4 + ui/packages/i18n/src/locales/ko.ts | 4 + ui/packages/i18n/src/locales/pt.ts | 4 + ui/packages/i18n/src/locales/ru.ts | 4 + ui/packages/i18n/src/locales/zh.ts | 4 + ui/packages/plugin-form/src/index.tsx | 7 + 31 files changed, 517 insertions(+), 74 deletions(-) create mode 100644 packages/platform-objects/src/identity/membership-role-help.ts create mode 100644 ui/packages/app-shell/src/views/actionDialogReturnFocus.ts 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/membership-role-help.ts b/packages/platform-objects/src/identity/membership-role-help.ts new file mode 100644 index 00000000000..01990c250fe --- /dev/null +++ b/packages/platform-objects/src/identity/membership-role-help.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * 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 used to explain the same choice two ways and name none + * of the roles. One line per role of the closed vocabulary (ADR-0108), in its + * display order; the param dialog keeps the line breaks. + */ +export const MEMBERSHIP_ROLE_PARAM_HELP = [ + '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.', +].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..36f641c452e 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. Editing them did nothing useful: invite-member + // renews a pending invitation with its STORED role whatever role is sent, + // and a changed email invited a different person. + 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` (`resend-invitation-confirm.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/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/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/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.tsx b/ui/packages/app-shell/src/views/ActionParamDialog.tsx index 0216174d3a2..31235d20843 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 ( -
+