Skip to content

Commit da42adc

Browse files
os-zhuangclaude
andcommitted
fix(platform-objects,spec): surface invite_user on the org page's default Members tab
The sys_organization record page (ADR-0081) opens on tab-0 Members, whose related-list toolbar carried only `add_member` (attach an already-registered user by id). The email-invite entry lived on tab-1 Invitations, so an admin looking to invite a teammate by email found no invite affordance at all. - sys_member declares its own `invite_user` on `list_toolbar`, ahead of `add_member` (declaration order is render order in the toolbar bridge). - The `email` param names `objectOverride: 'sys_invitation'` — sys_member has no `email` field, and an unresolvable field-backed param degrades silently to an untyped text input (ADR-0078). `role` resolves natively. - `add_member` is differentiated in chrome only: `variant: 'secondary'` + `icon: 'link-2'`. Behaviour, target and label unchanged. - PUBLIC_AUTH_FEATURES.organization.gatedInputs books the new gated action, as the feature-gate completeness guard requires. Fixes #11544 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4c9780c commit da42adc

9 files changed

Lines changed: 326 additions & 4 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
Surface the email-invite entry on the organization record page's default
7+
Members tab, and stop it rendering as a twin of "Add Member"
8+
9+
The in-shell Team surface (`sys_organization` record page, ADR-0081) opens on
10+
tab-0 **Members**, whose related-list toolbar carried exactly one action —
11+
`add_member`, which attaches an **already-registered** user by id. The
12+
email-invite entry, `invite_user`, was declared only on `sys_invitation` and
13+
`sys_user`, so it appeared only on tab-1 Invitations. An admin looking to
14+
"invite a teammate by email" landed on Members, found no invite affordance and
15+
concluded the product had none. The delivery half worked the whole time
16+
(`sendInvitationEmail`, template `auth.invitation`) — only the door was in
17+
another room.
18+
19+
`sys_member` now declares its own `invite_user` on `list_toolbar`, ahead of
20+
`add_member`: same endpoint (`/api/v1/auth/organization/invite-member`), same
21+
email + role inputs, and the same `requiresFeature: 'organization'` capability
22+
gate as the other two mirrors. Declaration order is render order in the
23+
related-list toolbar bridge, so the invite button sits left of the attach one.
24+
25+
**The `email` param names `objectOverride: 'sys_invitation'`, and must.**
26+
`sys_member` has no `email` field, so a verbatim copy of the `sys_invitation`
27+
declaration would leave the param unresolvable — the renderer answers that with
28+
a `type: 'text'` fallback labelled by the raw field name, which still submits
29+
and still looks fine (the ADR-0078 valid-but-inert class). `role` needs no
30+
override: `sys_member` declares it, from the same
31+
`BUILTIN_MEMBERSHIP_ROLE_OPTIONS` constant `sys_invitation` reads. A test now
32+
holds this over **all three** mirrors, so the next copy of any action cannot
33+
reintroduce the shape.
34+
35+
`add_member` keeps its behaviour and its label and is differentiated only in
36+
chrome — `variant: 'secondary'` and `icon: 'link-2'` (the "attach an existing
37+
record" icon `sys_account`'s `link_social` already uses) — so the two buttons
38+
no longer render as identical primary `user-plus` twins. Both halves are
39+
honoured by the renderer: it draws `primary` filled and every other variant
40+
outlined.
41+
42+
The `@objectstack/spec` half is one line of registry bookkeeping:
43+
`PUBLIC_AUTH_FEATURES.organization.gatedInputs` books the new gated action, as
44+
it already books the other twelve. No schema, export or authorable key changes.

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
625625
}
626626
},
627627
_actions: {
628+
invite_user: {
629+
label: "Invite User",
630+
successMessage: "Invitation sent"
631+
},
628632
add_member: {
629633
label: "Add Member",
630634
successMessage: "Member added"

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
625625
}
626626
},
627627
_actions: {
628+
invite_user: {
629+
label: "Invitar usuario",
630+
successMessage: "Invitación enviada"
631+
},
628632
add_member: {
629633
label: "Añadir miembro",
630634
successMessage: "Miembro añadido"

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
625625
}
626626
},
627627
_actions: {
628+
invite_user: {
629+
label: "ユーザーを招待",
630+
successMessage: "招待を送信しました"
631+
},
628632
add_member: {
629633
label: "メンバーを追加",
630634
successMessage: "メンバーを追加しました"

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
625625
}
626626
},
627627
_actions: {
628+
invite_user: {
629+
label: "邀请用户",
630+
successMessage: "邀请已发送"
631+
},
628632
add_member: {
629633
label: "添加成员",
630634
successMessage: "成员已添加"
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #11544 — the email-invite entry was UNREACHABLE from where admins actually
4+
// look. The org record page (ADR-0081) opens on tab-0 **Members**
5+
// (`sys_member`), whose toolbar carried exactly one action — `add_member`,
6+
// which attaches an ALREADY-REGISTERED user by id. `invite_user` lived only on
7+
// tab-1 Invitations. The maintainer, looking to "invite a teammate by email",
8+
// landed on Members and concluded the product had no invite entry at all.
9+
//
10+
// Two halves are pinned here, because the fix has two failure modes and they
11+
// fail in opposite directions:
12+
//
13+
// 1. **Reachability + mirror parity.** `invite_user` is now declared on three
14+
// objects (sys_user, sys_invitation, sys_member). Three copies of one
15+
// endpoint is exactly the shape that drifts, so the copies are compared to
16+
// EACH OTHER rather than to hand-copied literals.
17+
// 2. **Param resolvability — the trap this card walked into.** A field-backed
18+
// action param inherits its type / options / label from a field on the
19+
// action's parent object, or on `objectOverride` when it names another.
20+
// `sys_member` has no `email` field, so the sys_invitation copy could NOT
21+
// be mirrored verbatim: objectui's `resolveActionParam` answers an
22+
// unresolvable field-backed param with a `type: 'text'` fallback labelled
23+
// by the raw field name. Nothing throws, nothing goes red, and the dialog
24+
// still submits — the ADR-0078 valid-but-inert class. So the pin is
25+
// generic: EVERY field-backed param of EVERY mirror must name a field that
26+
// really exists on the object it resolves against.
27+
import { describe, expect, it } from 'vitest';
28+
import { SysInvitation } from './sys-invitation.object.js';
29+
import { SysMember } from './sys-member.object.js';
30+
import { SysUser } from './sys-user.object.js';
31+
32+
const INVITE_ENDPOINT = '/api/v1/auth/organization/invite-member';
33+
34+
/** The objects a param's `objectOverride` may resolve against, by name. */
35+
const OBJECTS_BY_NAME: Record<string, unknown> = {
36+
sys_user: SysUser,
37+
sys_member: SysMember,
38+
sys_invitation: SysInvitation,
39+
};
40+
41+
interface AnyAction {
42+
name?: string;
43+
label?: string;
44+
icon?: string;
45+
variant?: string;
46+
type?: string;
47+
target?: string;
48+
locations?: string[];
49+
visible?: { source?: string };
50+
params?: Array<{ name?: string; field?: string; objectOverride?: string; required?: boolean }>;
51+
}
52+
53+
/** Named action on an object, asserted present. */
54+
function action(object: unknown, name: string): AnyAction {
55+
const found = (((object as { actions?: AnyAction[] }).actions) ?? []).find((a) => a.name === name);
56+
expect(found, `${name} is declared`).toBeDefined();
57+
return found as AnyAction;
58+
}
59+
60+
/** Declared `list_toolbar` actions, in declaration order. */
61+
function toolbarActions(object: unknown): AnyAction[] {
62+
return (((object as { actions?: AnyAction[] }).actions) ?? [])
63+
.filter((a) => (a.locations ?? []).includes('list_toolbar'));
64+
}
65+
66+
/** The three declaration sites of `invite_user`, as [label, object] rows. */
67+
const MIRRORS: Array<[string, unknown]> = [
68+
['sys_user', SysUser],
69+
['sys_invitation', SysInvitation],
70+
['sys_member', SysMember],
71+
];
72+
73+
describe('invite_user — reachable from the default Members tab (#11544)', () => {
74+
it('is declared on sys_member, in the toolbar the Members tab renders', () => {
75+
// The regression itself: the whole defect was this action's ABSENCE from
76+
// this one object. `list_toolbar` is what the org record page's
77+
// `record:related_list` over sys_member surfaces as header buttons
78+
// (objectui `RelatedRecordActionsBridge.deriveActions` → `RelatedList`).
79+
const invite = action(SysMember, 'invite_user');
80+
expect(invite.locations).toContain('list_toolbar');
81+
expect(invite.type).toBe('api');
82+
expect(invite.target).toBe(INVITE_ENDPOINT);
83+
});
84+
85+
it('renders before add_member — declaration order IS render order', () => {
86+
// The bridge filters the child object's actions in array order and the
87+
// related list maps them in that order, so "which button is leftmost" is
88+
// decided here and nowhere else. A later reader appending the mirror to
89+
// the end of the array would restore the defect's visual half while every
90+
// other assertion in this file stayed green.
91+
const names = toolbarActions(SysMember).map((a) => a.name);
92+
expect(names).toContain('invite_user');
93+
expect(names).toContain('add_member');
94+
expect(names.indexOf('invite_user')).toBeLessThan(names.indexOf('add_member'));
95+
});
96+
97+
it('is the ONE primary button on the Members toolbar', () => {
98+
// The other half of the defect: two `variant: 'primary'` + `user-plus`
99+
// buttons side by side read as one affordance duplicated, not as two
100+
// different flows. objectui's `RelatedToolbarButton` draws `primary` as a
101+
// FILLED button and every other variant as an `outline` one, so this
102+
// assertion is about pixels an admin really sees, not about a key nobody
103+
// reads.
104+
const primaries = toolbarActions(SysMember).filter((a) => a.variant === 'primary');
105+
expect(primaries.map((a) => a.name)).toEqual(['invite_user']);
106+
});
107+
108+
it('does not share its icon with add_member', () => {
109+
// Icon and variant are pinned separately on purpose: either one alone
110+
// still leaves two buttons a glance cannot tell apart.
111+
const invite = action(SysMember, 'invite_user');
112+
const add = action(SysMember, 'add_member');
113+
expect(invite.icon).toBe('user-plus');
114+
expect(add.icon).not.toBe(invite.icon);
115+
expect(add.variant).not.toBe('primary');
116+
});
117+
118+
it('leaves add_member itself intact — still the attach-an-existing-user flow', () => {
119+
// Differentiating the chrome must not have touched the behaviour. The two
120+
// buttons are only worth distinguishing because they really do different
121+
// things: one mails an invitation, one binds an existing account.
122+
const add = action(SysMember, 'add_member');
123+
expect(add.target).toBe('/api/v1/auth/organization/add-member');
124+
expect((add.params ?? []).map((p) => p.name ?? p.field)).toContain('userId');
125+
});
126+
});
127+
128+
describe('invite_user — the three mirrors agree (#11544)', () => {
129+
it.each(MIRRORS)('%s dispatches the same endpoint from the same location', (_name, object) => {
130+
const invite = action(object, 'invite_user');
131+
expect(invite.type).toBe('api');
132+
expect(invite.target).toBe(INVITE_ENDPOINT);
133+
expect(invite.locations).toContain('list_toolbar');
134+
});
135+
136+
it.each(MIRRORS)('%s carries the same lowered `organization` capability gate', (_name, object) => {
137+
// `requiresFeature: 'organization'` is authoring sugar — it is lowered to a
138+
// CEL predicate at ObjectSchema.create time and the sugar key does not
139+
// survive (pinned in platform-objects.test.ts), so the gate is read from
140+
// its lowered form. A mirror that lost the gate would render a button that
141+
// 404s wherever the org capability is off.
142+
expect(action(object, 'invite_user').visible?.source).toBe('features.organization != false');
143+
});
144+
145+
it.each(MIRRORS)('%s asks for the same two inputs, email and role', (_name, object) => {
146+
const keys = (action(object, 'invite_user').params ?? []).map((p) => p.name ?? p.field);
147+
expect(keys).toEqual(['email', 'role']);
148+
});
149+
150+
it.each(MIRRORS)('%s requires both of them', (_name, object) => {
151+
// The endpoint has no default for either; an optional param here is a
152+
// dialog that submits an incomplete body and answers with a server error.
153+
for (const p of action(object, 'invite_user').params ?? []) {
154+
expect(p.required, `${String(p.field ?? p.name)} is required`).toBe(true);
155+
}
156+
});
157+
});
158+
159+
describe('invite_user — every field-backed param resolves to a real field (#11544)', () => {
160+
// THE load-bearing pin. Stated over all three mirrors rather than over the
161+
// one that was wrong, because the defect is a property of the DECLARATION
162+
// SHAPE, not of sys_member: any future mirror of any action that copies a
163+
// `{ field }` param onto an object that lacks that field lands here.
164+
it.each(MIRRORS)('%s', (name, object) => {
165+
const params = action(object, 'invite_user').params ?? [];
166+
expect(params.length).toBeGreaterThan(0);
167+
for (const p of params) {
168+
if (!p.field) continue; // inline param — nothing to resolve
169+
const ownerName = p.objectOverride ?? name;
170+
const owner = OBJECTS_BY_NAME[ownerName];
171+
expect(owner, `${ownerName} is a known object`).toBeDefined();
172+
const fields = (owner as { fields?: Record<string, unknown> }).fields ?? {};
173+
expect(
174+
Object.prototype.hasOwnProperty.call(fields, p.field),
175+
`${name}.invite_user param "${p.field}" resolves against ${ownerName}`,
176+
).toBe(true);
177+
}
178+
});
179+
180+
it('sys_member reaches sys_invitation for `email`, and its OWN field for `role`', () => {
181+
// Spelled out as its own case because it is the asymmetry a reader will
182+
// want to delete: sys_member declares `role` (from the same
183+
// BUILTIN_MEMBERSHIP_ROLE_OPTIONS constant sys_invitation reads) but has
184+
// no `email` column at all, so exactly one of the two params needs the
185+
// override. Dropping it is silent — see this file's header.
186+
const [email, role] = action(SysMember, 'invite_user').params ?? [];
187+
expect(email.field).toBe('email');
188+
expect(email.objectOverride).toBe('sys_invitation');
189+
expect(role.field).toBe('role');
190+
expect(role.objectOverride).toBeUndefined();
191+
expect(Object.prototype.hasOwnProperty.call(SysMember.fields ?? {}, 'email')).toBe(false);
192+
});
193+
});

‎packages/platform-objects/src/identity/sys-member.object.ts‎

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,50 @@ export const SysMember = ObjectSchema.create({
3636
// Row-level actions: better-auth `organization/update-member-role` and
3737
// `organization/remove-member`. Generic CRUD is suppressed on better-auth
3838
// managed tables, so these are the canonical edit/delete entry points.
39-
// The `add_member` toolbar action covers the admin "attach an existing
40-
// user directly without sending an invitation" flow.
39+
//
40+
// The toolbar carries the TWO ways a teammate arrives, in the order an
41+
// admin wants them: `invite_user` sends an email invitation (the common
42+
// case), `add_member` attaches an ALREADY-REGISTERED user directly.
4143
actions: [
44+
{
45+
// THIRD mirror of `invite_user` (sys_user, sys_invitation are the other
46+
// two — keep all three consistent). It is here because the org record
47+
// page (ADR-0081) opens on tab-0 **Members**, and the email-invite entry
48+
// used to live only on tab-1 Invitations: an admin looking to "invite a
49+
// teammate by email" landed on Members, saw only "Add Member" (attach an
50+
// existing user by id), and concluded the product had no invite entry.
51+
// Declaration order is render order — the related-list toolbar bridge
52+
// maps the child object's `list_toolbar` actions in array order
53+
// (objectui `RelatedRecordActionsBridge.deriveActions` →
54+
// `RelatedList`), so this sits left of `add_member`.
55+
//
56+
// ⚠️ `email` is NOT a field of sys_member, so unlike the sys_invitation
57+
// copy this param MUST name its owner via `objectOverride`. Without it
58+
// the field-backed param is unresolvable and objectui's
59+
// `resolveActionParam` falls back to `type: 'text'` with the raw field
60+
// name as its label — a dialog that still submits, but loses the email
61+
// value shape and the i18n label, with nothing red anywhere (ADR-0078
62+
// valid-but-inert metadata). Same device as sys_user's own copy, which
63+
// reaches for `sys_member` for the `role` half. `role` needs no override
64+
// here: sys_member declares it, from the same
65+
// BUILTIN_MEMBERSHIP_ROLE_OPTIONS constant sys_invitation reads.
66+
name: 'invite_user',
67+
label: 'Invite User',
68+
icon: 'user-plus',
69+
variant: 'primary',
70+
locations: ['list_toolbar'],
71+
type: 'api',
72+
target: '/api/v1/auth/organization/invite-member',
73+
// Same gate as the other two mirrors — the org CAPABILITY, not
74+
// multi-org (ADR-0081 D1).
75+
requiresFeature: 'organization',
76+
successMessage: 'Invitation sent',
77+
refreshAfter: true,
78+
params: [
79+
{ field: 'email', objectOverride: 'sys_invitation', required: true },
80+
{ field: 'role', required: true },
81+
],
82+
},
4283
{
4384
// Admin-only: directly attach an existing user to the active org,
4485
// bypassing the invite-accept flow. Better-auth:
@@ -55,10 +96,29 @@ export const SysMember = ObjectSchema.create({
5596
// — this action never sends it, so it never exercises the team half.
5697
// Pinned against a vendor bump that ADDS the fallback by
5798
// plugin-auth's `organization-add-member-team-fallback.test.ts`.
99+
//
100+
// Chrome DIFFERENTIATED from the `invite_user` sibling above, which used
101+
// to be a byte-identical `primary` + `user-plus` pair — the visual half
102+
// of the discoverability defect. Both halves are honoured by the
103+
// related-list toolbar renderer, so neither is decoration:
104+
// - `variant: 'secondary'` — objectui's `RelatedToolbarButton` maps
105+
// `primary` to a FILLED button and every other variant to an
106+
// `outline` one, so the invite entry reads as the primary path and
107+
// this one as the secondary. (That mapping is also why `secondary`
108+
// and `ghost` are indistinguishable HERE; the choice is `secondary`
109+
// because it is what the button means, not what this surface draws.)
110+
// - `icon: 'link-2'` — "attach an EXISTING record", the same icon
111+
// sys_account's `link_social` uses for attaching an existing external
112+
// identity. `user-plus` is reserved for the flows that bring a NEW
113+
// person in.
114+
// The LABEL is deliberately left alone: "Add Member" and "Invite User"
115+
// already differ, and the four translation bundles' hand-written values
116+
// fill only gaps — a renamed source label would leave three locales
117+
// reading the old text under a green gate.
58118
name: 'add_member',
59119
label: 'Add Member',
60-
icon: 'user-plus',
61-
variant: 'primary',
120+
icon: 'link-2',
121+
variant: 'secondary',
62122
locations: ['list_toolbar'],
63123
type: 'api',
64124
target: '/api/v1/auth/organization/add-member',

‎packages/platform-objects/src/platform-objects.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,9 @@ describe('feature-gate lowering matrix (#2874)', () => {
488488
['SysOrganization', SysOrganization, 'change_slug', MULTI_ORG],
489489
['SysUser', SysUser, 'invite_user', ORG],
490490
['SysUser', SysUser, 'create_user', 'features.admin == true'],
491+
// [#11544] Third mirror of `invite_user` — the Members tab's own copy of
492+
// the email-invite entry. Same gate as the sys_user / sys_invitation rows.
493+
['SysMember', SysMember, 'invite_user', ORG],
491494
['SysMember', SysMember, 'add_member', ORG],
492495
['SysMember', SysMember, 'update_member_role', ORG],
493496
['SysMember', SysMember, 'remove_member', ORG],

‎packages/spec/src/kernel/public-auth-features.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ export const PUBLIC_AUTH_FEATURES = {
103103
semantics: 'default-on',
104104
gatedInputs: [
105105
'sys_user.actions.invite_user',
106+
// [#11544] Third mirror of the email-invite entry — the org record
107+
// page's default Members tab renders sys_member's toolbar, so this is
108+
// where an admin looking to invite a teammate actually looks. Listed
109+
// ahead of `add_member` to match the object's own declaration order,
110+
// which IS the render order.
111+
'sys_member.actions.invite_user',
106112
'sys_member.actions.add_member',
107113
'sys_member.actions.update_member_role',
108114
'sys_member.actions.remove_member',

0 commit comments

Comments
 (0)