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
12 changes: 12 additions & 0 deletions .changeset/24271-org-rename-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/platform-objects': minor
'@objectstack/spec': patch
---

`sys_organization` now declares a `rename_organization` action ("Rename Organization") at `record_header` and `list_item`, so an organization record page can rename the organization. It collects `name` (required) and `logo`, both pre-filled from the record, and no `slug`. It calls better-auth's `/api/v1/auth/organization/update` exactly as `update_organization` does (the record's id as `organizationId`, the fields under `data`), and it is gated on `multiOrgEnabled` like the other organization actions. Because the request carries no slug, the organization keeps its slug. It is declared before `change_slug`, so on the record header it takes the third inline slot and `change_slug` moves into the `⋯` overflow menu.

`update_organization` (name, slug and logo, list row only) and `change_slug` are unchanged.

`@objectstack/spec` books the new action in `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs`, beside `update_organization`.

Clause-②: yes
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
label: "Set Active",
successMessage: "Active organization switched"
},
rename_organization: {
label: "Rename Organization",
successMessage: "Organization renamed"
},
change_slug: {
label: "Change Slug",
description: "Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
label: "Establecer como activa",
successMessage: "Organización activa cambiada"
},
rename_organization: {
label: "Renombrar organización",
successMessage: "Organización renombrada"
},
change_slug: {
label: "Cambiar slug",
description: "Renombrar el slug reescribe todos los subdominios de la plataforma para esta organización y reserva el slug anterior durante 90 días. ¿Continuar?",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "アクティブに設定",
successMessage: "アクティブ組織を切り替えました"
},
rename_organization: {
label: "組織名を変更",
successMessage: "組織名を変更しました"
},
change_slug: {
label: "スラッグを変更",
description: "スラッグを変更すると、この組織のすべてのプラットフォームサブドメインが書き換えられ、旧スラッグは 90 日間確保されます。続行しますか?",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "设为当前",
successMessage: "当前组织已切换"
},
rename_organization: {
label: "重命名组织",
successMessage: "组织已重命名"
},
change_slug: {
label: "修改标识符",
description: "重命名标识符会重写该组织所有平台子域名,旧标识符将保留占用 90 天。继续吗?",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// sys_organization's rename door: a name/logo-only action a record page can
// offer.
//
// `update_organization` renames too, but it is declared at `list_item` only (a
// record page's header never offers it) and it bundles `slug`, which the cloud
// orchestrator's `change_slug` governs where it exists. So the record page
// needs its own door for `name` and `logo`, one that sends no slug.
//
// What this file pins, from source:
// - the new action's declaration: its params are exactly `name` and `logo`,
// it sits at `record_header` and `list_item`, it targets better-auth's
// `organization/update` exactly as `update_organization` does, and it is
// gated on `multiOrgEnabled` like every sibling organization action;
// - one door per field on the record header: no `record_header` action that
// targets `organization/update` carries a `slug` param;
// - the header order: the rename takes an inline slot and `change_slug` folds
// into the overflow (the ordering model is described in
// `sys-organization-invite-action.test.ts`);
// - controls: `update_organization` and `change_slug` are unchanged.
//
// The runtime half, a rename through better-auth that reads the slug back
// unchanged, is `organization-rename-door.dogfood.test.ts` in
// `packages/qa/dogfood`.
import { describe, expect, it } from 'vitest';
import { featureGatePredicate } from '@objectstack/spec/kernel';
import { SysOrganization } from './sys-organization.object.js';

interface DeclaredParam {
field?: string;
name?: string;
required?: boolean;
defaultFromRow?: boolean;
}

interface DeclaredAction {
name?: string;
type?: string;
target?: string;
method?: string;
recordIdParam?: string;
bodyShape?: unknown;
order?: number;
locations?: string[];
visible?: { source?: string };
params?: DeclaredParam[];
}

const UPDATE_TARGET = '/api/v1/auth/organization/update';

/** objectui page:header's default desktop inline budget. */
const MAX_VISIBLE = 3;

const actions = (): DeclaredAction[] =>
((SysOrganization as { actions?: DeclaredAction[] }).actions) ?? [];

function action(name: string): DeclaredAction {
const found = actions().find((a) => a.name === name);
expect(found, `${name} is declared`).toBeDefined();
return found as DeclaredAction;
}

const paramKeys = (a: DeclaredAction): string[] => (a.params ?? []).map((p) => (p.field ?? p.name) as string);

/** The object's `record_header` actions, in the order the stable `order` sort gives them. */
function headerOrder(): string[] {
return actions()
.filter((a) => (a.locations ?? []).includes('record_header'))
.map((a, i) => ({ a, i }))
.sort((x, y) => (x.a.order ?? 0) - (y.a.order ?? 0) || x.i - y.i)
.map(({ a }) => a.name as string);
}

describe('sys_organization.rename_organization: the declaration', () => {
it('collects exactly `name` (required) and `logo`, both pre-filled from the row, and no `slug`', () => {
const rename = action('rename_organization');
expect(paramKeys(rename)).toEqual(['name', 'logo']);
const [name, logo] = rename.params ?? [];
expect(name).toMatchObject({ field: 'name', required: true, defaultFromRow: true });
expect(logo).toMatchObject({ field: 'logo', defaultFromRow: true });
expect(logo?.required).not.toBe(true);
});

it('is offered at the record header and on the list row', () => {
expect(action('rename_organization').locations).toEqual(['record_header', 'list_item']);
});

it("targets better-auth's organization/update exactly as update_organization does", () => {
const rename = action('rename_organization');
const update = action('update_organization');
expect(rename.type).toBe('api');
expect(rename.target).toBe(UPDATE_TARGET);
expect(rename.recordIdParam).toBe('organizationId');
expect(rename.bodyShape).toEqual({ wrap: 'data' });
expect({ type: rename.type, target: rename.target, method: rename.method, recordIdParam: rename.recordIdParam, bodyShape: rename.bodyShape })
.toEqual({ type: update.type, target: update.target, method: update.method, recordIdParam: update.recordIdParam, bodyShape: update.bodyShape });
});

it('is gated on multiOrgEnabled, the same lowered predicate as update_organization', () => {
const gate = featureGatePredicate('multiOrgEnabled');
expect(action('rename_organization').visible?.source).toBe(gate);
expect(action('update_organization').visible?.source).toBe(gate);
});
});

describe('sys_organization: one door per field on the record header', () => {
it('no record_header action that writes through organization/update collects a slug', () => {
const headerWriters = actions()
.filter((a) => (a.locations ?? []).includes('record_header'))
.filter((a) => a.target === UPDATE_TARGET);
expect(headerWriters.map((a) => a.name)).toEqual(['rename_organization']);
for (const a of headerWriters) {
expect(paramKeys(a), `${a.name} sends a slug to better-auth`).not.toContain('slug');
}
});

it('the rename takes an inline header slot and change_slug folds into the overflow', () => {
const order = headerOrder();
const names = actions().map((a) => a.name);
expect(names.indexOf('rename_organization')).toBeLessThan(names.indexOf('change_slug'));
expect(order.indexOf('rename_organization')).toBeGreaterThanOrEqual(0);
expect(order.indexOf('rename_organization')).toBeLessThan(MAX_VISIBLE);
expect(order.indexOf('change_slug')).toBeGreaterThanOrEqual(MAX_VISIBLE);
expect(order[order.length - 1]).toBe('leave_organization');
});
});

describe('sys_organization: the slug doors are unchanged (controls)', () => {
it('update_organization keeps name / slug / logo, its target and list_item alone', () => {
const update = action('update_organization');
expect(update.locations).toEqual(['list_item']);
expect(update.target).toBe(UPDATE_TARGET);
expect(paramKeys(update)).toEqual(['name', 'slug', 'logo']);
});

it("change_slug keeps its cloud orchestrator target, record_header alone and its one slug param", () => {
const change = action('change_slug');
expect(change.locations).toEqual(['record_header']);
expect(change.target).toBe('/api/v1/cloud/organizations/{id}/change-slug');
expect(paramKeys(change)).toEqual(['slug']);
});
});
49 changes: 44 additions & 5 deletions packages/platform-objects/src/identity/sys-organization.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ export const SysOrganization = ObjectSchema.create({

// Custom actions — generic create / delete are suppressed (better-auth-
// managed), and better-auth's own columns (`name`, `slug`, `logo`) are
// edited ONLY through `update_organization` below. The generic `update` the
// edited ONLY through better-auth's `organization/update`: the
// `update_organization` row action (all three) and the name/logo-only
// `rename_organization` (record header and row) below. The generic `update` the
// data door admits since #15873 reaches the platform-owned extension columns
// alone (see `userActions` above and `enable.apiMethods` at the bottom);
// admins still need to create new orgs from the Setup app.
Expand Down Expand Up @@ -194,10 +196,11 @@ export const SysOrganization = ObjectSchema.create({
variant: 'danger',
// Higher than every sibling (unset = 0), so it sorts LAST: with
// `invite_user` leading, the header's three inline slots go to the
// invite, `set_active_organization` and `change_slug`, and this one
// folds into the `⋯` overflow's destructive band (objectui's
// page:header split, default `maxVisible` 3). Without it, registration
// order kept it inline as the third button.
// invite, `set_active_organization` and `rename_organization`, and this
// one folds into the `⋯` overflow's destructive band (objectui's
// page:header split, default `maxVisible` 3), with `change_slug` in the
// overflow above it. Without it, registration order kept it inline as
// the third button.
order: 1,
mode: 'custom',
locations: ['list_item', 'record_header'],
Expand All @@ -210,6 +213,42 @@ export const SysOrganization = ObjectSchema.create({
successMessage: 'You have left the organization',
refreshAfter: true,
},
{
// Rename the organization: its `name` and `logo`, never its `slug`.
// The same better-auth door as `update_organization` above
// (`organization/update`, fields nested under `data`), with no `slug`
// param, so the body carries no slug and the stored one is kept:
// better-auth's slug-taken check and plugin-auth's
// `beforeUpdateOrganization` slug guard both run only on a sent slug.
//
// One door per field. A record page reaches no `list_item` action, so
// this one is declared at `record_header` too; where the cloud
// orchestrator governs the slug, that page offers this action and
// `change_slug`, and nothing on it sends a slug to better-auth.
// `update_organization` stays the slug door of a composition without the
// orchestrator, because `change_slug`'s target exists only in cloud.
//
// Declared BEFORE `change_slug`: both sort at `order` 0, so declaration
// order breaks the tie, the rename takes the header's third inline slot,
// and `change_slug` folds into the `⋯` overflow.
name: 'rename_organization',
label: 'Rename Organization',
icon: 'pencil',
variant: 'secondary',
mode: 'edit',
locations: ['record_header', 'list_item'],
type: 'api',
target: '/api/v1/auth/organization/update',
recordIdParam: 'organizationId',
bodyShape: { wrap: 'data' },
requiresFeature: 'multiOrgEnabled',
successMessage: 'Organization renamed',
refreshAfter: true,
params: [
{ field: 'name', required: true, defaultFromRow: true },
{ field: 'logo', defaultFromRow: true },
],
},
{
// Rename the organization slug (URL prefix). Backed by the cloud
// orchestrator at /api/v1/cloud/organizations/{id}/change-slug,
Expand Down
1 change: 1 addition & 0 deletions packages/platform-objects/src/platform-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,7 @@ describe('feature-gate lowering matrix (#2874)', () => {
const rows: Array<[string, { actions?: readonly { name?: string; visible?: unknown; params?: readonly unknown[] }[] }, string, string]> = [
['SysOrganization', SysOrganization, 'create_organization', MULTI_ORG],
['SysOrganization', SysOrganization, 'update_organization', MULTI_ORG],
['SysOrganization', SysOrganization, 'rename_organization', MULTI_ORG],
['SysOrganization', SysOrganization, 'delete_organization', MULTI_ORG],
['SysOrganization', SysOrganization, 'set_active_organization', MULTI_ORG],
['SysOrganization', SysOrganization, 'leave_organization', MULTI_ORG],
Expand Down
118 changes: 118 additions & 0 deletions packages/qa/dogfood/test/organization-rename-door.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `sys_organization.rename_organization` through the door it targets, on a
* booted showcase.
*
* The record page's rename door is a name/logo-only action on better-auth's
* `organization/update`, the endpoint `update_organization` uses, declared
* without a `slug` param. Its whole claim is that a rename through it keeps the
* stored slug: better-auth writes only the keys it is sent, its slug-taken
* check runs only on a sent slug, and plugin-auth's `beforeUpdateOrganization`
* slug guard returns at once when no slug is sent. This file proves that claim
* against the real stack instead of a source reading.
*
* The request is built from the REGISTERED action, the way the console's api
* action runner builds it (objectui `useConsoleActionRuntime`): the params go
* under `bodyShape.wrap`, the record id under `recordIdParam`, and the target is
* the declared one. So a slug param added to the declaration would reach the
* body here, and the slug assertion below would see what a user would get.
*
* The organization is minted the way `organization-update-door.dogfood.test.ts`
* mints it: `multiTenant: 'posture-only'` opens `organization/create` on the
* plain showcase (it activates the posture, never the wall), and the creator is
* the owner, a grade better-auth grants `organization: ['update']`.
*
* The declaration half (params, locations, gate, header order, and the two
* slug doors unchanged) is `sys-organization-rename-action.test.ts` in
* `@objectstack/platform-objects`.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { type VerifyStack } from '@objectstack/verify';
import { bootShowcase } from './showcase-boot.js';

interface RegisteredAction {
name?: string;
type?: string;
target?: string;
recordIdParam?: string;
bodyShape?: { wrap?: string };
params?: Array<{ field?: string; name?: string }>;
}

const SLUG = 'rename-door-org';

describe('sys_organization.rename_organization: a rename through better-auth keeps the slug', () => {
let stack: VerifyStack;
let token: string;
let orgId: string;
let rename: RegisteredAction;

/** The organization row as the data door serves it back. */
const readOrg = async (id: string): Promise<Record<string, unknown>> => {
const res = await stack.apiAs(token, 'GET', `/data/sys_organization/${id}`);
expect(res.status).toBe(200);
const body: any = await res.json();
return (body.record ?? body.data ?? {}) as Record<string, unknown>;
};

/** Run the registered action the way the console's api runner does. */
const runRename = async (values: Record<string, unknown>) => {
const keys = (rename.params ?? []).map((p) => (p.field ?? p.name) as string);
const collected = Object.fromEntries(keys.filter((k) => k in values).map((k) => [k, values[k]]));
const wrap = rename.bodyShape?.wrap;
const body: Record<string, unknown> = wrap ? { [wrap]: collected } : { ...collected };
body[rename.recordIdParam as string] = orgId;
// `apiAs` paths are relative to the REST prefix the target names.
const path = String(rename.target).replace(/^\/api\/v1/, '');
return stack.apiAs(token, 'POST', path, body);
};

beforeAll(async () => {
stack = await bootShowcase({ multiTenant: 'posture-only' });
token = await stack.signIn();

const created = await stack.apiAs(token, 'POST', '/auth/organization/create', {
name: 'Rename Door Org',
slug: SLUG,
});
expect(created.status, `organization/create returned ${created.status}: ${await created.clone().text()}`).toBe(200);
orgId = String(((await created.json()) as { id?: string }).id);
expect(orgId).toBeTruthy();

const engine = await stack.kernel.getServiceAsync<any>('objectql');
const schema = engine?.getSchema?.('sys_organization');
rename = ((schema?.actions ?? []) as RegisteredAction[]).find((a) => a?.name === 'rename_organization') as RegisteredAction;
expect(rename, 'rename_organization is registered').toBeTruthy();
}, 120_000);

afterAll(async () => { await stack?.stop?.(); });

it('the registered action is the declared one: organization/update, name and logo under data, the row id as organizationId', () => {
expect(rename.type).toBe('api');
expect(rename.target).toBe('/api/v1/auth/organization/update');
expect(rename.bodyShape).toEqual({ wrap: 'data' });
expect(rename.recordIdParam).toBe('organizationId');
expect((rename.params ?? []).map((p) => p.field ?? p.name)).toEqual(['name', 'logo']);
});

it('renames the organization and sets its logo, and the slug reads back unchanged', async () => {
const before = await readOrg(orgId);
expect(before.slug).toBe(SLUG);

// A new slug is among the values on purpose. The param dialog collects
// declared params alone, so with no `slug` param it never reaches the
// body, and the stored slug must survive the rename.
const res = await runRename({ name: 'Renamed Door Org', logo: 'https://example.com/rename-door.png', slug: 'smuggled-slug' });
expect(res.status, `organization/update returned ${res.status}: ${await res.clone().text()}`).toBe(200);
const answered: any = await res.json();
expect(answered.name).toBe('Renamed Door Org');
expect(answered.slug).toBe(SLUG);

const after = await readOrg(orgId);
expect(after.name).toBe('Renamed Door Org');
expect(after.logo).toBe('https://example.com/rename-door.png');
expect(after.slug).toBe(SLUG);
});
});
Loading
Loading