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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/invite-entry-on-members-tab.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -616,6 +616,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
}
},
_actions: {
invite_user: {
label: "Invite User",
successMessage: "Invitation sent"
},
add_member: {
label: "Add Member",
successMessage: "Member added"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -616,6 +616,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
}
},
_actions: {
invite_user: {
label: "Invitar usuario",
successMessage: "Invitación enviada"
},
add_member: {
label: "Añadir miembro",
successMessage: "Miembro añadido"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -616,6 +616,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
}
},
_actions: {
invite_user: {
label: "ユーザーを招待",
successMessage: "招待を送信しました"
},
add_member: {
label: "メンバーを追加",
successMessage: "メンバーを追加しました"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -616,6 +616,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
}
},
_actions: {
invite_user: {
label: "邀请用户",
successMessage: "邀请已发送"
},
add_member: {
label: "添加成员",
successMessage: "成员已添加"
Expand Down
193 changes: 193 additions & 0 deletions packages/platform-objects/src/identity/invite-entry-toolbar.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown> = {
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<string, unknown> }).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);
});
});
68 changes: 64 additions & 4 deletions packages/platform-objects/src/identity/sys-member.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand All@@ -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',
Expand Down
3 changes: 3 additions & 0 deletions packages/platform-objects/src/platform-objects.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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],
Expand Down
6 changes: 6 additions & 0 deletions packages/spec/src/kernel/public-auth-features.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
Loading