diff --git a/.changeset/invite-entry-on-members-tab.md b/.changeset/invite-entry-on-members-tab.md new file mode 100644 index 0000000000..cf2bebfe71 --- /dev/null +++ b/.changeset/invite-entry-on-members-tab.md @@ -0,0 +1,44 @@ +--- +'@objectstack/platform-objects': patch +'@objectstack/spec': patch +--- + +Surface the email-invite entry on the organization record page's default +Members tab, and stop it rendering as a twin of "Add Member" + +The in-shell Team surface (`sys_organization` record page, ADR-0081) opens on +tab-0 **Members**, whose related-list toolbar carried exactly one action — +`add_member`, which attaches an **already-registered** user by id. The +email-invite entry, `invite_user`, was declared only on `sys_invitation` and +`sys_user`, so it appeared only on tab-1 Invitations. An admin looking to +"invite a teammate by email" landed on Members, found no invite affordance and +concluded the product had none. The delivery half worked the whole time +(`sendInvitationEmail`, template `auth.invitation`) — only the door was in +another room. + +`sys_member` now declares its own `invite_user` on `list_toolbar`, ahead of +`add_member`: same endpoint (`/api/v1/auth/organization/invite-member`), same +email + role inputs, and the same `requiresFeature: 'organization'` capability +gate as the other two mirrors. Declaration order is render order in the +related-list toolbar bridge, so the invite button sits left of the attach one. + +**The `email` param names `objectOverride: 'sys_invitation'`, and must.** +`sys_member` has no `email` field, so a verbatim copy of the `sys_invitation` +declaration would leave the param unresolvable — the renderer answers that with +a `type: 'text'` fallback labelled by the raw field name, which still submits +and still looks fine (the ADR-0078 valid-but-inert class). `role` needs no +override: `sys_member` declares it, from the same +`BUILTIN_MEMBERSHIP_ROLE_OPTIONS` constant `sys_invitation` reads. A test now +holds this over **all three** mirrors, so the next copy of any action cannot +reintroduce the shape. + +`add_member` keeps its behaviour and its label and is differentiated only in +chrome — `variant: 'secondary'` and `icon: 'link-2'` (the "attach an existing +record" icon `sys_account`'s `link_social` already uses) — so the two buttons +no longer render as identical primary `user-plus` twins. Both halves are +honoured by the renderer: it draws `primary` filled and every other variant +outlined. + +The `@objectstack/spec` half is one line of registry bookkeeping: +`PUBLIC_AUTH_FEATURES.organization.gatedInputs` books the new gated action, as +it already books the other twelve. No schema, export or authorable key changes. 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 95b59b929a..143aa862a8 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -616,6 +616,10 @@ export const enObjects: NonNullable = { } }, _actions: { + invite_user: { + label: "Invite User", + successMessage: "Invitation sent" + }, add_member: { label: "Add Member", successMessage: "Member added" 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 321c6fd757..4c1505edf9 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 @@ -616,6 +616,10 @@ export const esESObjects: NonNullable = { } }, _actions: { + invite_user: { + label: "Invitar usuario", + successMessage: "Invitación enviada" + }, add_member: { label: "Añadir miembro", successMessage: "Miembro añadido" 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 1b05a26b99..ff3eaad560 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 @@ -616,6 +616,10 @@ export const jaJPObjects: NonNullable = { } }, _actions: { + invite_user: { + label: "ユーザーを招待", + successMessage: "招待を送信しました" + }, add_member: { label: "メンバーを追加", successMessage: "メンバーを追加しました" 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 07b21e4ba3..9915346e11 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 @@ -616,6 +616,10 @@ export const zhCNObjects: NonNullable = { } }, _actions: { + invite_user: { + label: "邀请用户", + successMessage: "邀请已发送" + }, add_member: { label: "添加成员", successMessage: "成员已添加" diff --git a/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts b/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts new file mode 100644 index 0000000000..92e4613ce9 --- /dev/null +++ b/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #11544 — the email-invite entry was UNREACHABLE from where admins actually +// look. The org record page (ADR-0081) opens on tab-0 **Members** +// (`sys_member`), whose toolbar carried exactly one action — `add_member`, +// which attaches an ALREADY-REGISTERED user by id. `invite_user` lived only on +// tab-1 Invitations. The maintainer, looking to "invite a teammate by email", +// landed on Members and concluded the product had no invite entry at all. +// +// Two halves are pinned here, because the fix has two failure modes and they +// fail in opposite directions: +// +// 1. **Reachability + mirror parity.** `invite_user` is now declared on three +// objects (sys_user, sys_invitation, sys_member). Three copies of one +// endpoint is exactly the shape that drifts, so the copies are compared to +// EACH OTHER rather than to hand-copied literals. +// 2. **Param resolvability — the trap this card walked into.** A field-backed +// action param inherits its type / options / label from a field on the +// action's parent object, or on `objectOverride` when it names another. +// `sys_member` has no `email` field, so the sys_invitation copy could NOT +// be mirrored verbatim: objectui's `resolveActionParam` answers an +// unresolvable field-backed param with a `type: 'text'` fallback labelled +// by the raw field name. Nothing throws, nothing goes red, and the dialog +// still submits — the ADR-0078 valid-but-inert class. So the pin is +// generic: EVERY field-backed param of EVERY mirror must name a field that +// really exists on the object it resolves against. +import { describe, expect, it } from 'vitest'; +import { SysInvitation } from './sys-invitation.object.js'; +import { SysMember } from './sys-member.object.js'; +import { SysUser } from './sys-user.object.js'; + +const INVITE_ENDPOINT = '/api/v1/auth/organization/invite-member'; + +/** The objects a param's `objectOverride` may resolve against, by name. */ +const OBJECTS_BY_NAME: Record = { + sys_user: SysUser, + sys_member: SysMember, + sys_invitation: SysInvitation, +}; + +interface AnyAction { + name?: string; + label?: string; + icon?: string; + variant?: string; + type?: string; + target?: string; + locations?: string[]; + visible?: { source?: string }; + params?: Array<{ name?: string; field?: string; objectOverride?: string; required?: boolean }>; +} + +/** Named action on an object, asserted present. */ +function action(object: unknown, name: string): AnyAction { + const found = (((object as { actions?: AnyAction[] }).actions) ?? []).find((a) => a.name === name); + expect(found, `${name} is declared`).toBeDefined(); + return found as AnyAction; +} + +/** Declared `list_toolbar` actions, in declaration order. */ +function toolbarActions(object: unknown): AnyAction[] { + return (((object as { actions?: AnyAction[] }).actions) ?? []) + .filter((a) => (a.locations ?? []).includes('list_toolbar')); +} + +/** The three declaration sites of `invite_user`, as [label, object] rows. */ +const MIRRORS: Array<[string, unknown]> = [ + ['sys_user', SysUser], + ['sys_invitation', SysInvitation], + ['sys_member', SysMember], +]; + +describe('invite_user — reachable from the default Members tab (#11544)', () => { + it('is declared on sys_member, in the toolbar the Members tab renders', () => { + // The regression itself: the whole defect was this action's ABSENCE from + // this one object. `list_toolbar` is what the org record page's + // `record:related_list` over sys_member surfaces as header buttons + // (objectui `RelatedRecordActionsBridge.deriveActions` → `RelatedList`). + const invite = action(SysMember, 'invite_user'); + expect(invite.locations).toContain('list_toolbar'); + expect(invite.type).toBe('api'); + expect(invite.target).toBe(INVITE_ENDPOINT); + }); + + it('renders before add_member — declaration order IS render order', () => { + // The bridge filters the child object's actions in array order and the + // related list maps them in that order, so "which button is leftmost" is + // decided here and nowhere else. A later reader appending the mirror to + // the end of the array would restore the defect's visual half while every + // other assertion in this file stayed green. + const names = toolbarActions(SysMember).map((a) => a.name); + expect(names).toContain('invite_user'); + expect(names).toContain('add_member'); + expect(names.indexOf('invite_user')).toBeLessThan(names.indexOf('add_member')); + }); + + it('is the ONE primary button on the Members toolbar', () => { + // The other half of the defect: two `variant: 'primary'` + `user-plus` + // buttons side by side read as one affordance duplicated, not as two + // different flows. objectui's `RelatedToolbarButton` draws `primary` as a + // FILLED button and every other variant as an `outline` one, so this + // assertion is about pixels an admin really sees, not about a key nobody + // reads. + const primaries = toolbarActions(SysMember).filter((a) => a.variant === 'primary'); + expect(primaries.map((a) => a.name)).toEqual(['invite_user']); + }); + + it('does not share its icon with add_member', () => { + // Icon and variant are pinned separately on purpose: either one alone + // still leaves two buttons a glance cannot tell apart. + const invite = action(SysMember, 'invite_user'); + const add = action(SysMember, 'add_member'); + expect(invite.icon).toBe('user-plus'); + expect(add.icon).not.toBe(invite.icon); + expect(add.variant).not.toBe('primary'); + }); + + it('leaves add_member itself intact — still the attach-an-existing-user flow', () => { + // Differentiating the chrome must not have touched the behaviour. The two + // buttons are only worth distinguishing because they really do different + // things: one mails an invitation, one binds an existing account. + const add = action(SysMember, 'add_member'); + expect(add.target).toBe('/api/v1/auth/organization/add-member'); + expect((add.params ?? []).map((p) => p.name ?? p.field)).toContain('userId'); + }); +}); + +describe('invite_user — the three mirrors agree (#11544)', () => { + it.each(MIRRORS)('%s dispatches the same endpoint from the same location', (_name, object) => { + const invite = action(object, 'invite_user'); + expect(invite.type).toBe('api'); + expect(invite.target).toBe(INVITE_ENDPOINT); + expect(invite.locations).toContain('list_toolbar'); + }); + + it.each(MIRRORS)('%s carries the same lowered `organization` capability gate', (_name, object) => { + // `requiresFeature: 'organization'` is authoring sugar — it is lowered to a + // CEL predicate at ObjectSchema.create time and the sugar key does not + // survive (pinned in platform-objects.test.ts), so the gate is read from + // its lowered form. A mirror that lost the gate would render a button that + // 404s wherever the org capability is off. + expect(action(object, 'invite_user').visible?.source).toBe('features.organization != false'); + }); + + it.each(MIRRORS)('%s asks for the same two inputs, email and role', (_name, object) => { + const keys = (action(object, 'invite_user').params ?? []).map((p) => p.name ?? p.field); + expect(keys).toEqual(['email', 'role']); + }); + + it.each(MIRRORS)('%s requires both of them', (_name, object) => { + // The endpoint has no default for either; an optional param here is a + // dialog that submits an incomplete body and answers with a server error. + for (const p of action(object, 'invite_user').params ?? []) { + expect(p.required, `${String(p.field ?? p.name)} is required`).toBe(true); + } + }); +}); + +describe('invite_user — every field-backed param resolves to a real field (#11544)', () => { + // THE load-bearing pin. Stated over all three mirrors rather than over the + // one that was wrong, because the defect is a property of the DECLARATION + // SHAPE, not of sys_member: any future mirror of any action that copies a + // `{ field }` param onto an object that lacks that field lands here. + it.each(MIRRORS)('%s', (name, object) => { + const params = action(object, 'invite_user').params ?? []; + expect(params.length).toBeGreaterThan(0); + for (const p of params) { + if (!p.field) continue; // inline param — nothing to resolve + const ownerName = p.objectOverride ?? name; + const owner = OBJECTS_BY_NAME[ownerName]; + expect(owner, `${ownerName} is a known object`).toBeDefined(); + const fields = (owner as { fields?: Record }).fields ?? {}; + expect( + Object.prototype.hasOwnProperty.call(fields, p.field), + `${name}.invite_user param "${p.field}" resolves against ${ownerName}`, + ).toBe(true); + } + }); + + it('sys_member reaches sys_invitation for `email`, and its OWN field for `role`', () => { + // Spelled out as its own case because it is the asymmetry a reader will + // want to delete: sys_member declares `role` (from the same + // BUILTIN_MEMBERSHIP_ROLE_OPTIONS constant sys_invitation reads) but has + // no `email` column at all, so exactly one of the two params needs the + // override. Dropping it is silent — see this file's header. + const [email, role] = action(SysMember, 'invite_user').params ?? []; + expect(email.field).toBe('email'); + expect(email.objectOverride).toBe('sys_invitation'); + expect(role.field).toBe('role'); + expect(role.objectOverride).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(SysMember.fields ?? {}, 'email')).toBe(false); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 042c632d53..5dfe73977a 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -36,9 +36,50 @@ export const SysMember = ObjectSchema.create({ // Row-level actions: better-auth `organization/update-member-role` and // `organization/remove-member`. Generic CRUD is suppressed on better-auth // managed tables, so these are the canonical edit/delete entry points. - // The `add_member` toolbar action covers the admin "attach an existing - // user directly without sending an invitation" flow. + // + // The toolbar carries the TWO ways a teammate arrives, in the order an + // admin wants them: `invite_user` sends an email invitation (the common + // case), `add_member` attaches an ALREADY-REGISTERED user directly. actions: [ + { + // THIRD mirror of `invite_user` (sys_user, sys_invitation are the other + // two — keep all three consistent). It is here because the org record + // page (ADR-0081) opens on tab-0 **Members**, and the email-invite entry + // used to live only on tab-1 Invitations: an admin looking to "invite a + // teammate by email" landed on Members, saw only "Add Member" (attach an + // existing user by id), and concluded the product had no invite entry. + // Declaration order is render order — the related-list toolbar bridge + // maps the child object's `list_toolbar` actions in array order + // (objectui `RelatedRecordActionsBridge.deriveActions` → + // `RelatedList`), so this sits left of `add_member`. + // + // ⚠️ `email` is NOT a field of sys_member, so unlike the sys_invitation + // copy this param MUST name its owner via `objectOverride`. Without it + // the field-backed param is unresolvable and objectui's + // `resolveActionParam` falls back to `type: 'text'` with the raw field + // name as its label — a dialog that still submits, but loses the email + // value shape and the i18n label, with nothing red anywhere (ADR-0078 + // valid-but-inert metadata). Same device as sys_user's own copy, which + // reaches for `sys_member` for the `role` half. `role` needs no override + // here: sys_member declares it, from the same + // BUILTIN_MEMBERSHIP_ROLE_OPTIONS constant sys_invitation reads. + name: 'invite_user', + label: 'Invite User', + icon: 'user-plus', + variant: 'primary', + locations: ['list_toolbar'], + type: 'api', + target: '/api/v1/auth/organization/invite-member', + // Same gate as the other two mirrors — the org CAPABILITY, not + // multi-org (ADR-0081 D1). + requiresFeature: 'organization', + successMessage: 'Invitation sent', + refreshAfter: true, + params: [ + { field: 'email', objectOverride: 'sys_invitation', required: true }, + { field: 'role', required: true }, + ], + }, { // Admin-only: directly attach an existing user to the active org, // bypassing the invite-accept flow. Better-auth: @@ -55,10 +96,29 @@ export const SysMember = ObjectSchema.create({ // — this action never sends it, so it never exercises the team half. // Pinned against a vendor bump that ADDS the fallback by // plugin-auth's `organization-add-member-team-fallback.test.ts`. + // + // Chrome DIFFERENTIATED from the `invite_user` sibling above, which used + // to be a byte-identical `primary` + `user-plus` pair — the visual half + // of the discoverability defect. Both halves are honoured by the + // related-list toolbar renderer, so neither is decoration: + // - `variant: 'secondary'` — objectui's `RelatedToolbarButton` maps + // `primary` to a FILLED button and every other variant to an + // `outline` one, so the invite entry reads as the primary path and + // this one as the secondary. (That mapping is also why `secondary` + // and `ghost` are indistinguishable HERE; the choice is `secondary` + // because it is what the button means, not what this surface draws.) + // - `icon: 'link-2'` — "attach an EXISTING record", the same icon + // sys_account's `link_social` uses for attaching an existing external + // identity. `user-plus` is reserved for the flows that bring a NEW + // person in. + // The LABEL is deliberately left alone: "Add Member" and "Invite User" + // already differ, and the four translation bundles' hand-written values + // fill only gaps — a renamed source label would leave three locales + // reading the old text under a green gate. name: 'add_member', label: 'Add Member', - icon: 'user-plus', - variant: 'primary', + icon: 'link-2', + variant: 'secondary', locations: ['list_toolbar'], type: 'api', target: '/api/v1/auth/organization/add-member', diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index a63575c09e..cb85306fc9 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -522,6 +522,9 @@ describe('feature-gate lowering matrix (#2874)', () => { ['SysOrganization', SysOrganization, 'change_slug', MULTI_ORG], ['SysUser', SysUser, 'invite_user', ORG], ['SysUser', SysUser, 'create_user', 'features.admin == true'], + // [#11544] Third mirror of `invite_user` — the Members tab's own copy of + // the email-invite entry. Same gate as the sys_user / sys_invitation rows. + ['SysMember', SysMember, 'invite_user', ORG], ['SysMember', SysMember, 'add_member', ORG], ['SysMember', SysMember, 'update_member_role', ORG], ['SysMember', SysMember, 'remove_member', ORG], diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index 5ea50c80ac..73828ea0c4 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -103,6 +103,12 @@ export const PUBLIC_AUTH_FEATURES = { semantics: 'default-on', gatedInputs: [ 'sys_user.actions.invite_user', + // [#11544] Third mirror of the email-invite entry — the org record + // page's default Members tab renders sys_member's toolbar, so this is + // where an admin looking to invite a teammate actually looks. Listed + // ahead of `add_member` to match the object's own declaration order, + // which IS the render order. + 'sys_member.actions.invite_user', 'sys_member.actions.add_member', 'sys_member.actions.update_member_role', 'sys_member.actions.remove_member',